Files
excalidraw-box/src/db.ts
2026-05-24 15:32:36 +00:00

210 lines
4.9 KiB
TypeScript

import { Database } from "bun:sqlite";
import { randomUUID } from "node:crypto";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { DEFAULT_TITLE, type DrawingMeta, type DrawingRecord, type ScenePayload } from "./shared";
import { coerceStoredScene, emptyScene, normalizeTitle } from "./scene";
type DrawingRow = {
id: string;
title: string;
data: string;
createdAt: string;
updatedAt: string;
};
type DrawingMetaRow = Omit<DrawingRow, "data">;
export type DrawingStore = ReturnType<typeof createDrawingStore>;
function createId(): string {
return randomUUID().replaceAll("-", "").slice(0, 12);
}
function resolveDatabasePath(): string {
if (process.env.DATABASE_PATH) {
return process.env.DATABASE_PATH;
}
try {
mkdirSync("/data", { recursive: true });
return "/data/excalidraw.sqlite";
} catch {
return `${process.cwd()}/data/excalidraw.sqlite`;
}
}
function readMeta(row: DrawingMetaRow | null | undefined): DrawingMeta | null {
if (!row) {
return null;
}
return {
id: row.id,
title: row.title,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
if (!row) {
return null;
}
return {
...readMeta(row)!,
scene: coerceStoredScene(JSON.parse(row.data)),
};
}
export function createDrawingStore(databasePath = resolveDatabasePath()) {
mkdirSync(dirname(databasePath), { recursive: true });
const db = new Database(databasePath, { create: true, strict: true });
db.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS drawings (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS drawings_updated_at_idx
ON drawings(updated_at DESC, created_at DESC);
`);
const listQuery = db.query(`
SELECT
id,
title,
created_at AS createdAt,
updated_at AS updatedAt
FROM drawings
ORDER BY updated_at DESC, created_at DESC
`);
const getQuery = db.query(`
SELECT
id,
title,
data,
created_at AS createdAt,
updated_at AS updatedAt
FROM drawings
WHERE id = ?1
`);
const createQuery = db.query(`
INSERT INTO drawings (id, title, data)
VALUES (?1, ?2, ?3)
`);
const updateSceneQuery = db.query(`
UPDATE drawings
SET data = ?2, updated_at = CURRENT_TIMESTAMP
WHERE id = ?1
`);
const updateTitleQuery = db.query(`
UPDATE drawings
SET title = ?2, updated_at = CURRENT_TIMESTAMP
WHERE id = ?1
`);
const updateBothQuery = db.query(`
UPDATE drawings
SET title = ?2, data = ?3, updated_at = CURRENT_TIMESTAMP
WHERE id = ?1
`);
const deleteQuery = db.query(`
DELETE FROM drawings
WHERE id = ?1
`);
function listDrawings(): DrawingMeta[] {
return (listQuery.all() as DrawingMetaRow[]).map((row) => readMeta(row)!);
}
function getDrawing(id: string): DrawingRecord | null {
return readRow(getQuery.get(id) as DrawingRow | null | undefined);
}
function createDrawing(title = DEFAULT_TITLE): DrawingRecord {
const id = createId();
createQuery.run(id, normalizeTitle(title), JSON.stringify(emptyScene()));
return getDrawing(id)!;
}
function getLatestDrawing(): DrawingMeta | null {
return listDrawings()[0] ?? null;
}
function ensureInitialDrawing(): DrawingMeta {
return getLatestDrawing() ?? createDrawing();
}
function updateDrawing(id: string, update: { scene?: ScenePayload; title?: string }): DrawingRecord | null {
const existing = getDrawing(id);
if (!existing) {
return null;
}
const hasScene = update.scene !== undefined;
const hasTitle = update.title !== undefined;
if (!hasScene && !hasTitle) {
return existing;
}
if (hasScene && hasTitle) {
updateBothQuery.run(id, normalizeTitle(update.title), JSON.stringify(update.scene));
} else if (hasScene) {
updateSceneQuery.run(id, JSON.stringify(update.scene));
} else {
updateTitleQuery.run(id, normalizeTitle(update.title));
}
return getDrawing(id);
}
function deleteDrawing(id: string): { deleted: boolean; next: DrawingMeta | null } {
const changes = Number(deleteQuery.run(id).changes);
if (changes === 0) {
return { deleted: false, next: null };
}
return {
deleted: true,
next: ensureInitialDrawing(),
};
}
function close() {
db.close(false);
}
return {
databasePath,
close,
listDrawings,
getDrawing,
createDrawing,
getLatestDrawing,
ensureInitialDrawing,
updateDrawing,
deleteDrawing,
};
}
let defaultStore: DrawingStore | null = null;
export function getDefaultDrawingStore(): DrawingStore {
defaultStore ??= createDrawingStore();
return defaultStore;
}