From 9727c2d254f7005a8d04c72b21a92a055477573d Mon Sep 17 00:00:00 2001 From: JaredSystems Date: Sun, 13 Sep 2026 09:50:50 -0400 Subject: [PATCH 1/2] fix: await transaction commit and preserve finalized handles --- example/src/tests/index.ts | 1 + example/src/tests/transactions.ts | 103 ++++++++++++++++++++++++++++++ src/functions.ts | 7 +- 3 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 example/src/tests/transactions.ts diff --git a/example/src/tests/index.ts b/example/src/tests/index.ts index 3e229734..24823996 100644 --- a/example/src/tests/index.ts +++ b/example/src/tests/index.ts @@ -7,4 +7,5 @@ import "./queries"; import "./reactive"; import "./storage"; import "./tokenizer"; +import "./transactions"; import "./web"; diff --git a/example/src/tests/transactions.ts b/example/src/tests/transactions.ts new file mode 100644 index 00000000..4b205daa --- /dev/null +++ b/example/src/tests/transactions.ts @@ -0,0 +1,103 @@ +import { type DB, isTurso, open, type Transaction } from "@op-engineering/op-sqlite"; +import { afterEach, beforeEach, describe, expect, it } from "@op-engineering/op-test"; + +describe("Transaction finalization", () => { + let db: DB; + + beforeEach(async () => { + db = open({ name: "transaction-finalization.sqlite", encryptionKey: "test" }); + await db.execute("CREATE TABLE IF NOT EXISTS entries (id INTEGER PRIMARY KEY);"); + }); + + afterEach(() => { + db.delete(); + }); + + // Turso uses a different engine; this regression requires SQLite deferred FKs. + if (!isTurso()) { + it("Rejects a failed automatic COMMIT, rolls back, and runs the next transaction", async () => { + await db.execute("PRAGMA foreign_keys = ON;"); + await db.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY);"); + await db.execute( + "CREATE TABLE child (parent_id INTEGER REFERENCES parent(id) DEFERRABLE INITIALLY DEFERRED);", + ); + let insertCompleted = false; + let commitError: unknown; + try { + await db.transaction(async (tx) => { + await tx.execute("INSERT INTO entries VALUES (1);"); + await tx.execute("INSERT INTO child VALUES (999);"); + insertCompleted = true; + }); + } catch (error) { + commitError = error; + } + + expect(insertCompleted).toEqual(true); + expect(String(commitError).toLowerCase().includes("foreign key")).toEqual(true); + expect((await db.execute("SELECT * FROM entries;")).rows).toDeepEqual([]); + expect((await db.execute("SELECT * FROM child;")).rows).toDeepEqual([]); + + await db.transaction(async (tx) => { + await tx.execute("INSERT INTO parent VALUES (1);"); + await tx.execute("INSERT INTO child VALUES (1);"); + }); + expect((await db.execute("SELECT * FROM child;")).rows).toDeepEqual([{ parent_id: 1 }]); + }); + } + + for (const finalization of ["automatic commit", "explicit commit", "explicit rollback"] as const) { + it(`Keeps the transaction handle finalized after ${finalization}`, async () => { + let retained: Transaction | undefined; + await db.transaction(async (tx) => { + retained = tx; + await tx.execute("INSERT INTO entries VALUES (1);"); + if (finalization === "explicit commit") await tx.commit(); + if (finalization === "explicit rollback") tx.rollback(); + }); + if (!retained) throw new Error("Transaction callback did not run"); + const tx = retained; + + // An expired handle must not write or finalize a later transaction. + await db.transaction(async (next) => { + for (const operation of [ + () => tx.execute("INSERT INTO entries VALUES (99);"), + () => tx.commit(), + () => tx.rollback(), + ]) { + let rejected = false; + try { + await operation(); + } catch (error) { + rejected = String(error).includes("finalized transaction"); + } + expect(rejected).toEqual(true); + } + await next.execute("INSERT INTO entries VALUES (2);"); + }); + expect((await db.execute("SELECT id FROM entries ORDER BY id;")).rows).toDeepEqual( + finalization === "explicit rollback" ? [{ id: 2 }] : [{ id: 1 }, { id: 2 }], + ); + }); + } + + it("Preserves a callback error after explicit COMMIT without rolling back committed data", async () => { + const failure = new Error("callback failed after COMMIT"); + let actual: unknown; + try { + await db.transaction(async (tx) => { + await tx.execute("INSERT INTO entries VALUES (1);"); + await tx.commit(); + throw failure; + }); + } catch (error) { + actual = error; + } + expect(actual).toBe(failure); + expect((await db.execute("SELECT * FROM entries;")).rows).toDeepEqual([{ id: 1 }]); + await db.transaction(async (tx) => { + await tx.execute("INSERT INTO entries VALUES (2);"); + }); + expect((await db.execute("SELECT COUNT(*) AS count FROM entries;")).rows[0]?.count).toEqual(2); + }); +}); diff --git a/src/functions.ts b/src/functions.ts index 76dde5ec..85271044 100644 --- a/src/functions.ts +++ b/src/functions.ts @@ -208,9 +208,9 @@ function enhanceDB(db: _InternalDB, options: DBParams): DB { } const result = enhancedDb.executeSync("COMMIT;"); - await db.flushPendingReactiveQueries(); - isFinalized = true; + + await db.flushPendingReactiveQueries(); return result; }; @@ -238,7 +238,7 @@ function enhanceDB(db: _InternalDB, options: DBParams): DB { }); if (!isFinalized) { - commit(); + await commit(); } } catch (executionError) { if (!isFinalized) { @@ -248,7 +248,6 @@ function enhanceDB(db: _InternalDB, options: DBParams): DB { throw executionError; } finally { lock.inProgress = false; - isFinalized = false; startNextTransaction(); } } From 924f1b284401c18c8cdd592215aacf1e3019b754 Mon Sep 17 00:00:00 2001 From: JaredSystems Date: Mon, 14 Sep 2026 09:15:06 -0400 Subject: [PATCH 2/2] test: separate transaction lifecycle from libsql native errors --- example/src/tests/transactions.ts | 55 ++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/example/src/tests/transactions.ts b/example/src/tests/transactions.ts index 4b205daa..20a2c2ea 100644 --- a/example/src/tests/transactions.ts +++ b/example/src/tests/transactions.ts @@ -1,4 +1,4 @@ -import { type DB, isTurso, open, type Transaction } from "@op-engineering/op-sqlite"; +import { type DB, isLibsql, isTurso, open, type Transaction } from "@op-engineering/op-sqlite"; import { afterEach, beforeEach, describe, expect, it } from "@op-engineering/op-test"; describe("Transaction finalization", () => { @@ -13,8 +13,53 @@ describe("Transaction finalization", () => { db.delete(); }); - // Turso uses a different engine; this regression requires SQLite deferred FKs. - if (!isTurso()) { + it("Propagates an automatic COMMIT error, rolls back, and releases the transaction queue", async () => { + const failure = new Error("injected COMMIT failure"); + const executeSync = db.executeSync; + let failCommit = true; + let rollbacks = 0; + let actual: unknown; + let nextCompleted = false; + + // Inject at the synchronous execution boundary to cover every backend, + // independently of its native error reporting. All other SQL is real. + db.executeSync = (query, params) => { + if (query === "COMMIT;" && failCommit) { + failCommit = false; + throw failure; + } + if (query === "ROLLBACK;") rollbacks++; + return executeSync(query, params); + }; + try { + const first = db + .transaction(async (tx) => { + await tx.execute("INSERT INTO entries VALUES (1);"); + }) + .catch((error) => { + actual = error; + }); + const next = db.transaction(async (tx) => { + await tx.execute("INSERT INTO entries VALUES (2);"); + nextCompleted = true; + }); + // Wait for both even if one fails, before restoring or deleting the DB. + const results = await Promise.allSettled([first, next]); + expect(actual).toBe(failure); + expect(results[1]?.status).toEqual("fulfilled"); + expect(rollbacks).toEqual(1); + expect(nextCompleted).toEqual(true); + expect((await db.execute("SELECT id FROM entries ORDER BY id;")).rows).toDeepEqual([{ id: 2 }]); + } finally { + db.executeSync = executeSync; + } + }); + + // Turso uses a different engine. The libSQL bridge currently drops errors + // returned by libsql_next_row, including this deferred-FK COMMIT error. + // Keep native error propagation separate from the JS lifecycle regression + // above, which runs on all backends. See the CI investigation in #455. + if (!isTurso() && !isLibsql()) { it("Rejects a failed automatic COMMIT, rolls back, and runs the next transaction", async () => { await db.execute("PRAGMA foreign_keys = ON;"); await db.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY);"); @@ -34,7 +79,9 @@ describe("Transaction finalization", () => { } expect(insertCompleted).toEqual(true); - expect(String(commitError).toLowerCase().includes("foreign key")).toEqual(true); + if (!String(commitError).toLowerCase().includes("foreign key")) { + throw new Error(`Expected a foreign-key COMMIT error, received: ${String(commitError)}`); + } expect((await db.execute("SELECT * FROM entries;")).rows).toDeepEqual([]); expect((await db.execute("SELECT * FROM child;")).rows).toDeepEqual([]);