refactor: drop legacy db and scene fallback

This commit is contained in:
2026-05-31 12:29:58 +00:00
parent ab786a7861
commit 83191055bd
5 changed files with 80 additions and 29 deletions
+31
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
@@ -59,6 +60,36 @@ describe("api", () => {
cleanup(); cleanup();
}); });
test("returns 500 when stored scene payload is invalid", () => {
const dir = mkdtempSync(join(tmpdir(), "excali-api-"));
const databasePath = join(dir, "test.sqlite");
const db = new Database(databasePath, { create: true, strict: true });
db.exec(`
CREATE TABLE 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
);
INSERT INTO drawings (id, title, data, revision)
VALUES ('broken', 'Broken', '{"appState":{},"files":{}}', 0);
`);
db.close(false);
const store = createDrawingStore(databasePath);
const api = createApi(store);
const response = api.getDrawing(
Object.assign(new Request("http://local/api/drawings/broken"), { params: { id: "broken" } }),
);
expect(response.status).toBe(500);
store.close();
rmSync(dir, { recursive: true, force: true });
});
test("updates a drawing scene", async () => { test("updates a drawing scene", async () => {
const { api, cleanup } = withApi(); const { api, cleanup } = withApi();
const created = await api.createDrawing().json(); const created = await api.createDrawing().json();
+4
View File
@@ -68,6 +68,7 @@ export function createApi(store: DrawingStore) {
}, },
getDrawing(request: RouteRequest) { getDrawing(request: RouteRequest) {
try {
const id = request.params?.id; const id = request.params?.id;
const drawing = id ? store.getDrawing(id) : null; const drawing = id ? store.getDrawing(id) : null;
@@ -79,6 +80,9 @@ export function createApi(store: DrawingStore) {
...toMeta(drawing), ...toMeta(drawing),
...drawing.scene, ...drawing.scene,
}); });
} catch (error) {
return errorResponse(error);
}
}, },
async updateDrawing(request: RouteRequest) { async updateDrawing(request: RouteRequest) {
+26 -5
View File
@@ -83,7 +83,7 @@ describe("drawing store", () => {
expect(store.getDrawing(created.id)?.scene.elements).toEqual([{ id: "first" }]); expect(store.getDrawing(created.id)?.scene.elements).toEqual([{ id: "first" }]);
}); });
test("migrates existing databases to include revision", () => { test("fails to open databases missing the revision column", () => {
const dir = mkdtempSync(join(tmpdir(), "excali-")); const dir = mkdtempSync(join(tmpdir(), "excali-"));
cleanup.push(dir); cleanup.push(dir);
const databasePath = join(dir, "test.sqlite"); const databasePath = join(dir, "test.sqlite");
@@ -102,10 +102,31 @@ describe("drawing store", () => {
`); `);
db.close(false); db.close(false);
const store = createDrawingStore(databasePath); expect(() => createDrawingStore(databasePath)).toThrow(/revision/i);
expect(store.getDrawing("legacy")?.revision).toBe(0); });
const updated = store.updateDrawing("legacy", { title: "Migrated" }); test("throws when reading drawings with invalid stored scene payloads", () => {
expect(updated.ok ? updated.drawing.revision : null).toBe(1); const dir = mkdtempSync(join(tmpdir(), "excali-"));
cleanup.push(dir);
const databasePath = join(dir, "test.sqlite");
const db = new Database(databasePath, { create: true, strict: true });
db.exec(`
CREATE TABLE 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
);
INSERT INTO drawings (id, title, data, revision)
VALUES ('broken', 'Broken', '{"appState":{},"files":{}}', 0);
`);
db.close(false);
const store = createDrawingStore(databasePath);
expect(() => store.getDrawing("broken")).toThrow(/Invalid stored scene/);
store.close();
}); });
}); });
+10 -7
View File
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import { mkdirSync } from "node:fs"; import { mkdirSync } from "node:fs";
import { dirname } from "node:path"; import { dirname } from "node:path";
import { DEFAULT_TITLE, type DrawingMeta, type DrawingRecord, type ScenePayload } from "./shared"; import { DEFAULT_TITLE, type DrawingMeta, type DrawingRecord, type ScenePayload } from "./shared";
import { coerceStoredScene, emptyScene, normalizeTitle } from "./scene"; import { emptyScene, normalizeScene, normalizeTitle } from "./scene";
type DrawingRow = { type DrawingRow = {
id: string; id: string;
@@ -59,9 +59,17 @@ function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
return null; 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 { return {
...readMeta(row)!, ...readMeta(row)!,
scene: coerceStoredScene(JSON.parse(row.data)), scene,
}; };
} }
@@ -85,11 +93,6 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
ON drawings(updated_at DESC, created_at DESC); ON drawings(updated_at DESC, created_at DESC);
`); `);
const columns = db.query("PRAGMA table_info(drawings)").all() as Array<{ name: string }>;
if (!columns.some((column) => column.name === "revision")) {
db.exec("ALTER TABLE drawings ADD COLUMN revision INTEGER NOT NULL DEFAULT 0");
}
const listQuery = db.query(` const listQuery = db.query(`
SELECT SELECT
id, id,
-8
View File
@@ -87,11 +87,3 @@ export function parseJsonBody<T = unknown>(text: string): T {
export function parseSceneText(text: string): ScenePayload { export function parseSceneText(text: string): ScenePayload {
return normalizeScene(parseJsonBody(text)); return normalizeScene(parseJsonBody(text));
} }
export function coerceStoredScene(input: unknown): ScenePayload {
try {
return normalizeScene(input);
} catch {
return emptyScene();
}
}