From 3a0ff80ee3f67dde685fa51cd3f378c6ac191bec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:46:10 +0100 Subject: [PATCH] test: cover sqlite transaction guardrails --- .../check-database-first-legacy-stores.mjs | 1 + src/infra/sqlite-transaction.test.ts | 74 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 src/infra/sqlite-transaction.test.ts diff --git a/scripts/check-database-first-legacy-stores.mjs b/scripts/check-database-first-legacy-stores.mjs index c65e2bd0df7..eecde842bed 100644 --- a/scripts/check-database-first-legacy-stores.mjs +++ b/scripts/check-database-first-legacy-stores.mjs @@ -96,6 +96,7 @@ const legacyStoreMarkers = [ { label: "Matrix sync store JSON", pattern: /\bbot-storage\.json\b/u }, { label: "Matrix storage metadata JSON", pattern: /\bstorage-meta\.json\b/u }, { label: "Matrix inbound dedupe JSON", pattern: /\binbound-dedupe\.json\b/u }, + { label: "Matrix startup verification JSON", pattern: /\bstartup-verification\.json\b/u }, { label: "Discord model-picker preferences JSON", pattern: /\bmodel-picker-preferences\.json\b/u, diff --git a/src/infra/sqlite-transaction.test.ts b/src/infra/sqlite-transaction.test.ts new file mode 100644 index 00000000000..026cb9c8b36 --- /dev/null +++ b/src/infra/sqlite-transaction.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { requireNodeSqlite } from "./node-sqlite.js"; +import { runSqliteImmediateTransactionSync } from "./sqlite-transaction.js"; + +const openDatabases: Array = []; + +function createDatabase(): import("node:sqlite").DatabaseSync { + const { DatabaseSync } = requireNodeSqlite(); + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE entries (id TEXT NOT NULL PRIMARY KEY, value TEXT NOT NULL);"); + openDatabases.push(db); + return db; +} + +function readEntries(db: import("node:sqlite").DatabaseSync): string[] { + return db + .prepare("SELECT id FROM entries ORDER BY id") + .all() + .map((row) => (row as { id: string }).id); +} + +afterEach(() => { + for (const db of openDatabases.splice(0)) { + db.close(); + } +}); + +describe("runSqliteImmediateTransactionSync", () => { + it("keeps outer writes when a nested savepoint rolls back", () => { + const db = createDatabase(); + + runSqliteImmediateTransactionSync(db, () => { + db.prepare("INSERT INTO entries(id, value) VALUES (?, ?)").run("outer", "kept"); + expect(() => + runSqliteImmediateTransactionSync(db, () => { + db.prepare("INSERT INTO entries(id, value) VALUES (?, ?)").run("inner", "rolled back"); + throw new Error("nested failure"); + }), + ).toThrow("nested failure"); + }); + + expect(readEntries(db)).toEqual(["outer"]); + }); + + it("commits nested savepoint writes with the outer transaction", () => { + const db = createDatabase(); + + runSqliteImmediateTransactionSync(db, () => { + db.prepare("INSERT INTO entries(id, value) VALUES (?, ?)").run("outer", "kept"); + runSqliteImmediateTransactionSync(db, () => { + db.prepare("INSERT INTO entries(id, value) VALUES (?, ?)").run("inner", "kept"); + }); + }); + + expect(readEntries(db)).toEqual(["inner", "outer"]); + }); + + it("rejects Promise-returning operations and rolls back their synchronous writes", () => { + const db = createDatabase(); + + expect(() => + runSqliteImmediateTransactionSync(db, async () => { + db.prepare("INSERT INTO entries(id, value) VALUES (?, ?)").run("async", "rolled back"); + return "done"; + }), + ).toThrow("must be synchronous"); + expect(readEntries(db)).toEqual([]); + + runSqliteImmediateTransactionSync(db, () => { + db.prepare("INSERT INTO entries(id, value) VALUES (?, ?)").run("after", "works"); + }); + expect(readEntries(db)).toEqual(["after"]); + }); +});