refactor: reorganize src structure

This commit is contained in:
2026-05-31 12:46:21 +00:00
parent f349617427
commit 25bf171f6b
21 changed files with 26 additions and 26 deletions
+208
View File
@@ -0,0 +1,208 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createApi } from "./api";
import { createDrawingStore } from "./db";
function withApi() {
const dir = mkdtempSync(join(tmpdir(), "excali-api-"));
const store = createDrawingStore(join(dir, "test.sqlite"));
const api = createApi(store);
return {
api,
cleanup() {
store.close();
rmSync(dir, { recursive: true, force: true });
},
};
}
describe("api", () => {
test("creates a drawing with dark theme by default", async () => {
const { api, cleanup } = withApi();
const created = await api.createDrawing().json();
expect(created.appState.theme).toBe("dark");
expect(created.revision).toBe(0);
cleanup();
});
test("returns 400 for invalid JSON", async () => {
const { api, cleanup } = withApi();
const drawing = await api.createDrawing().json();
const response = await api.updateDrawing(
Object.assign(
new Request(`http://local/api/drawings/${drawing.id}`, {
method: "PUT",
body: "{",
}),
{ params: { id: drawing.id } },
),
);
expect(response.status).toBe(400);
cleanup();
});
test("returns 404 for missing drawing", () => {
const { api, cleanup } = withApi();
const response = api.getDrawing(
Object.assign(new Request("http://local/api/drawings/missing"), { params: { id: "missing" } }),
);
expect(response.status).toBe(404);
cleanup();
});
test("updates a drawing scene", async () => {
const { api, cleanup } = withApi();
const created = await api.createDrawing().json();
const response = await api.updateDrawing(
Object.assign(
new Request(`http://local/api/drawings/${created.id}`, {
method: "PUT",
body: JSON.stringify({
elements: [{ id: "shape" }],
appState: {},
files: {},
expectedRevision: created.revision,
}),
}),
{ params: { id: created.id } },
),
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.drawing.revision).toBe(1);
cleanup();
});
test("does not increment revisions for equivalent normalized scenes", async () => {
const { api, cleanup } = withApi();
const created = await api.createDrawing().json();
await api.updateDrawing(
Object.assign(
new Request(`http://local/api/drawings/${created.id}`, {
method: "PUT",
body: JSON.stringify({
elements: [{ id: "shape" }],
appState: { gridSize: 20 },
files: {},
expectedRevision: 0,
}),
}),
{ params: { id: created.id } },
),
);
const response = await api.updateDrawing(
Object.assign(
new Request(`http://local/api/drawings/${created.id}`, {
method: "PUT",
body: JSON.stringify({
elements: [{ id: "shape" }],
appState: { gridSize: 20, selectedElementIds: { shape: true } },
files: {},
expectedRevision: 0,
}),
}),
{ params: { id: created.id } },
),
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.drawing.revision).toBe(1);
cleanup();
});
test("updates scene and title with a matching expected revision", async () => {
const { api, cleanup } = withApi();
const created = await api.createDrawing().json();
const response = await api.updateDrawing(
Object.assign(
new Request(`http://local/api/drawings/${created.id}`, {
method: "PUT",
body: JSON.stringify({
title: "Synced",
elements: [{ id: "shape" }],
appState: {},
files: {},
expectedRevision: 0,
}),
}),
{ params: { id: created.id } },
),
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.drawing.title).toBe("Synced");
expect(body.drawing.revision).toBe(1);
cleanup();
});
test("returns 409 for stale expected revisions without overwriting", async () => {
const { api, cleanup } = withApi();
const created = await api.createDrawing().json();
await api.updateDrawing(
Object.assign(
new Request(`http://local/api/drawings/${created.id}`, {
method: "PUT",
body: JSON.stringify({
elements: [{ id: "server" }],
appState: {},
files: {},
expectedRevision: 0,
}),
}),
{ params: { id: created.id } },
),
);
const conflict = await api.updateDrawing(
Object.assign(
new Request(`http://local/api/drawings/${created.id}`, {
method: "PUT",
body: JSON.stringify({
elements: [{ id: "stale" }],
appState: {},
files: {},
expectedRevision: 0,
}),
}),
{ params: { id: created.id } },
),
);
expect(conflict.status).toBe(409);
const body = await conflict.json();
expect(body).toEqual({
ok: false,
error: "Drawing changed externally",
drawing: {
id: created.id,
title: created.title,
createdAt: body.drawing.createdAt,
updatedAt: body.drawing.updatedAt,
revision: 1,
},
});
const stored = await api.getDrawing(
Object.assign(new Request(`http://local/api/drawings/${created.id}`), { params: { id: created.id } }),
).json();
expect(stored.elements).toEqual([{ id: "server" }]);
cleanup();
});
});
+154
View File
@@ -0,0 +1,154 @@
import { type DrawingStore } from "./db";
import { type DrawingMeta } from "../core/shared";
import { HttpError, normalizeTitle, parseJsonBody, parseSceneText } from "../core/scene";
type RouteRequest = Request & {
params?: Record<string, string>;
};
function json(data: unknown, init?: ResponseInit): Response {
return Response.json(data, init);
}
function errorResponse(error: unknown): Response {
if (error instanceof HttpError) {
return json({ ok: false, error: error.message }, { status: error.status });
}
if (error instanceof Error && error.name === "AbortError") {
return json({ ok: false, error: "Request aborted" }, { status: 499 });
}
console.error(error);
return json({ ok: false, error: "Internal server error" }, { status: 500 });
}
function toMeta(drawing: DrawingMeta): DrawingMeta {
return {
id: drawing.id,
title: drawing.title,
createdAt: drawing.createdAt,
updatedAt: drawing.updatedAt,
revision: drawing.revision,
};
}
function parseExpectedRevision(raw: Record<string, unknown>): number | undefined {
if (!Object.hasOwn(raw, "expectedRevision")) {
return undefined;
}
const value = raw.expectedRevision;
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new HttpError(400, "expectedRevision must be a non-negative integer");
}
return value;
}
export function createApi(store: DrawingStore) {
return {
health() {
return json({ ok: true });
},
listDrawings() {
return json(store.listDrawings());
},
createDrawing() {
const drawing = store.createDrawing();
return json(
{
...toMeta(drawing),
...drawing.scene,
},
{ status: 201 },
);
},
getDrawing(request: RouteRequest) {
try {
const id = request.params?.id;
const drawing = id ? store.getDrawing(id) : null;
if (!drawing) {
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
}
return json({
...toMeta(drawing),
...drawing.scene,
});
} catch (error) {
return errorResponse(error);
}
},
async updateDrawing(request: RouteRequest) {
try {
const id = request.params?.id;
if (!id) {
throw new HttpError(400, "Missing drawing id");
}
const text = await request.text();
const raw = parseJsonBody<Record<string, unknown>>(text);
const hasTitle = Object.hasOwn(raw, "title");
const hasScene =
Object.hasOwn(raw, "elements") ||
Object.hasOwn(raw, "appState") ||
Object.hasOwn(raw, "files");
const expectedRevision = parseExpectedRevision(raw);
if (!hasTitle && !hasScene) {
throw new HttpError(400, "Nothing to update");
}
const scene = hasScene ? parseSceneText(text) : undefined;
const result = store.updateDrawing(id, {
scene,
title: hasTitle ? normalizeTitle(raw.title) : undefined,
expectedRevision,
});
if (!result.ok && result.reason === "not_found") {
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
}
if (!result.ok && result.reason === "conflict") {
return json(
{
ok: false,
error: "Drawing changed externally",
drawing: toMeta(result.drawing),
},
{ status: 409 },
);
}
const updated = result.drawing;
return json({
ok: true,
drawing: toMeta(updated),
});
} catch (error) {
return errorResponse(error);
}
},
deleteDrawing(request: RouteRequest) {
const id = request.params?.id;
if (!id) {
return json({ ok: false, error: "Missing drawing id" }, { status: 400 });
}
const { deleted, next } = store.deleteDrawing(id);
if (!deleted) {
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
}
return json({ ok: true, nextId: next?.id ?? null });
},
};
}
+138
View File
@@ -0,0 +1,138 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createDrawingStore } from "./db";
const cleanup: string[] = [];
afterEach(() => {
while (cleanup.length > 0) {
const dir = cleanup.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});
function createTempStore() {
const dir = mkdtempSync(join(tmpdir(), "excali-"));
cleanup.push(dir);
return createDrawingStore(join(dir, "test.sqlite"));
}
describe("drawing store", () => {
test("creates, updates, lists, and deletes drawings", () => {
const store = createTempStore();
const created = store.createDrawing();
expect(created.title).toBe("Untitled");
expect(created.revision).toBe(0);
expect(created.scene.appState.theme).toBe("dark");
expect(store.listDrawings()).toHaveLength(1);
expect(store.listDrawings()[0]?.revision).toBe(0);
const updated = store.updateDrawing(created.id, {
title: "Flow",
scene: {
elements: [{ id: "one" }],
appState: { gridSize: 20 },
files: {},
},
});
expect(updated.ok).toBe(true);
expect(updated.ok ? updated.drawing.title : null).toBe("Flow");
expect(updated.ok ? updated.drawing.revision : null).toBe(1);
expect(updated.ok ? updated.drawing.scene.elements : []).toHaveLength(1);
const removed = store.deleteDrawing(created.id);
expect(removed.deleted).toBe(true);
expect(store.listDrawings()).toHaveLength(1);
expect(removed.next?.id).not.toBe(created.id);
});
test("rejects stale expected revisions without overwriting the scene", () => {
const store = createTempStore();
const created = store.createDrawing();
const first = store.updateDrawing(created.id, {
expectedRevision: 0,
scene: {
elements: [{ id: "first" }],
appState: {},
files: {},
},
});
expect(first.ok).toBe(true);
const stale = store.updateDrawing(created.id, {
expectedRevision: 0,
scene: {
elements: [{ id: "stale" }],
appState: {},
files: {},
},
});
expect(stale.ok).toBe(false);
expect(stale.ok ? null : stale.reason).toBe("conflict");
expect(store.getDrawing(created.id)?.revision).toBe(1);
expect(store.getDrawing(created.id)?.scene.elements).toEqual([{ id: "first" }]);
});
test("keeps the revision unchanged when the scene is already stored", () => {
const store = createTempStore();
const created = store.createDrawing();
const scene = {
elements: [{ id: "same" }],
appState: {},
files: {},
};
const first = store.updateDrawing(created.id, { scene });
expect(first.ok ? first.drawing.revision : null).toBe(1);
const repeated = store.updateDrawing(created.id, { scene });
expect(repeated.ok).toBe(true);
expect(repeated.ok ? repeated.drawing.revision : null).toBe(1);
expect(store.getDrawing(created.id)?.revision).toBe(1);
});
test("accepts stale expected revisions when the scene is already stored", () => {
const store = createTempStore();
const created = store.createDrawing();
const scene = {
elements: [{ id: "same" }],
appState: {},
files: {},
};
const first = store.updateDrawing(created.id, { expectedRevision: 0, scene });
expect(first.ok ? first.drawing.revision : null).toBe(1);
const repeated = store.updateDrawing(created.id, { expectedRevision: 0, scene });
expect(repeated.ok).toBe(true);
expect(repeated.ok ? repeated.drawing.revision : null).toBe(1);
expect(store.getDrawing(created.id)?.scene.elements).toEqual([{ id: "same" }]);
});
test("increments the revision when the title changes but the scene does not", () => {
const store = createTempStore();
const created = store.createDrawing();
const scene = {
elements: [{ id: "same" }],
appState: {},
files: {},
};
const first = store.updateDrawing(created.id, { expectedRevision: 0, scene });
expect(first.ok ? first.drawing.revision : null).toBe(1);
const renamed = store.updateDrawing(created.id, { expectedRevision: 1, title: "Renamed", scene });
expect(renamed.ok).toBe(true);
expect(renamed.ok ? renamed.drawing.title : null).toBe("Renamed");
expect(renamed.ok ? renamed.drawing.revision : null).toBe(2);
});
});
+281
View File
@@ -0,0 +1,281 @@
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 "../core/shared";
import { emptyScene, normalizeScene, normalizeTitle } from "../core/scene";
type DrawingRow = {
id: string;
title: string;
data: string;
createdAt: string;
updatedAt: string;
revision: number;
};
type DrawingMetaRow = Omit<DrawingRow, "data">;
export type DrawingUpdateResult =
| { ok: true; drawing: DrawingRecord }
| { ok: false; reason: "not_found" }
| { ok: false; reason: "conflict"; drawing: DrawingRecord };
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,
revision: row.revision,
};
}
function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
if (!row) {
return null;
}
let scene: ScenePayload;
try {
scene = normalizeScene(JSON.parse(row.data));
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown scene parsing error";
throw new Error(`Invalid stored scene for drawing ${row.id}: ${message}`);
}
return {
...readMeta(row)!,
scene,
};
}
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,
revision INTEGER NOT NULL DEFAULT 0
);
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,
revision
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,
revision
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, revision = revision + 1
WHERE id = ?1
`);
const updateTitleQuery = db.query(`
UPDATE drawings
SET title = ?2, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
WHERE id = ?1
`);
const updateBothQuery = db.query(`
UPDATE drawings
SET title = ?2, data = ?3, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
WHERE id = ?1
`);
const updateSceneExpectedQuery = db.query(`
UPDATE drawings
SET data = ?2, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
WHERE id = ?1 AND revision = ?3
`);
const updateTitleExpectedQuery = db.query(`
UPDATE drawings
SET title = ?2, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
WHERE id = ?1 AND revision = ?3
`);
const updateBothExpectedQuery = db.query(`
UPDATE drawings
SET title = ?2, data = ?3, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
WHERE id = ?1 AND revision = ?4
`);
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; expectedRevision?: number },
): DrawingUpdateResult {
const hasScene = update.scene !== undefined;
const hasTitle = update.title !== undefined;
const existingRow = getQuery.get(id) as DrawingRow | null | undefined;
if (!existingRow) {
return { ok: false, reason: "not_found" };
}
const existing = readRow(existingRow)!;
if (!hasScene && !hasTitle) {
return { ok: true, drawing: existing };
}
const nextTitle = hasTitle ? normalizeTitle(update.title) : existingRow.title;
const nextScene = hasScene ? JSON.stringify(update.scene) : existingRow.data;
const titleChanged = nextTitle !== existingRow.title;
const sceneChanged = nextScene !== existingRow.data;
if (!titleChanged && !sceneChanged) {
return { ok: true, drawing: existing };
}
if (update.expectedRevision !== undefined && update.expectedRevision !== existingRow.revision) {
return { ok: false, reason: "conflict", drawing: existing };
}
let changes: number;
if (sceneChanged && titleChanged) {
changes =
update.expectedRevision === undefined
? Number(updateBothQuery.run(id, nextTitle, nextScene).changes)
: Number(updateBothExpectedQuery.run(id, nextTitle, nextScene, update.expectedRevision).changes);
} else if (sceneChanged) {
changes =
update.expectedRevision === undefined
? Number(updateSceneQuery.run(id, nextScene).changes)
: Number(updateSceneExpectedQuery.run(id, nextScene, update.expectedRevision).changes);
} else {
changes =
update.expectedRevision === undefined
? Number(updateTitleQuery.run(id, nextTitle).changes)
: Number(updateTitleExpectedQuery.run(id, nextTitle, update.expectedRevision).changes);
}
const drawing = getDrawing(id);
if (changes > 0 && drawing) {
return { ok: true, drawing };
}
if (!drawing) {
return { ok: false, reason: "not_found" };
}
return { ok: false, reason: "conflict", drawing };
}
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;
}
+19
View File
@@ -0,0 +1,19 @@
import index from "../client/index.html";
import { createServer } from "./http-server";
export function createDevServer() {
const server = createServer();
return {
...server,
routes: {
...server.routes,
"/d/:id": index,
},
} satisfies Parameters<typeof Bun.serve>[0];
}
if (import.meta.main) {
const server = Bun.serve(createDevServer());
console.log(`Listening on http://${server.hostname}:${server.port}`);
}
+70
View File
@@ -0,0 +1,70 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createDrawingStore, type DrawingStore } from "./db";
import { createServer } from "./http-server";
const cleanup: string[] = [];
const stores: DrawingStore[] = [];
afterEach(() => {
while (stores.length > 0) {
stores.pop()?.close();
}
while (cleanup.length > 0) {
const dir = cleanup.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});
function createServerFixture() {
const dir = mkdtempSync(join(tmpdir(), "excali-server-"));
cleanup.push(dir);
const store = createDrawingStore(join(dir, "test.sqlite"));
stores.push(store);
return {
store,
server: createServer({ drawingStore: store }),
};
}
describe("createServer", () => {
test("redirects / to the latest drawing", () => {
const { server, store } = createServerFixture();
const response = server.routes["/"]?.(new Request("http://local/")) as Response;
const created = store.listDrawings();
expect(created).toHaveLength(1);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe(`http://local/d/${created[0]?.id}`);
});
test("keeps Bun responsible for API routes", async () => {
const { server } = createServerFixture();
const response = await server.routes["/api/health"]?.GET?.();
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual({ ok: true });
});
test("does not expose a Bun static route for drawings in production", () => {
const { server } = createServerFixture();
expect(Object.hasOwn(server.routes, "/d/:id")).toBe(false);
});
test("returns JSON 404 for unmatched requests", async () => {
const { server } = createServerFixture();
const response = await server.fetch(new Request("http://local/index-abc123.js"));
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ ok: false, error: "Not found" });
});
});
+40
View File
@@ -0,0 +1,40 @@
import { createApi } from "./api";
import { type DrawingStore, getDefaultDrawingStore } from "./db";
type CreateServerOptions = {
drawingStore?: DrawingStore;
};
export function createServer(options: CreateServerOptions = {}) {
const drawingStore = options.drawingStore ?? getDefaultDrawingStore();
const api = createApi(drawingStore);
return {
port: Number(process.env.PORT ?? "3000"),
hostname: process.env.HOST ?? "localhost",
routes: {
"/": (request: Request) => {
const drawing = drawingStore.ensureInitialDrawing();
return Response.redirect(new URL(`/d/${drawing.id}`, request.url), 302);
},
"/api/health": {
GET: () => api.health(),
},
"/api/drawings": {
GET: () => api.listDrawings(),
POST: () => api.createDrawing(),
},
"/api/drawings/:id": {
GET: (request: Request & { params: Record<string, string> }) => api.getDrawing(request),
PUT: (request: Request & { params: Record<string, string> }) => api.updateDrawing(request),
DELETE: (request: Request & { params: Record<string, string> }) => api.deleteDrawing(request),
},
},
fetch: (_request: Request) => Response.json({ ok: false, error: "Not found" }, { status: 404 }),
} satisfies Parameters<typeof Bun.serve>[0];
}
if (import.meta.main) {
const server = Bun.serve(createServer());
console.log(`Listening on http://${server.hostname}:${server.port}`);
}