refactor: reorganize src structure
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user