From a299c338ce1ca1d87995bb710c5bfa6d8229cd57 Mon Sep 17 00:00:00 2001 From: robobun Date: Wed, 8 Jul 2026 10:34:38 +0000 Subject: [PATCH 01/10] sql(sqlite): detect SQLite's automatic ROLLBACK inside sql.begin() SQLite's ON CONFLICT ROLLBACK, INSERT OR ROLLBACK, and RAISE(ROLLBACK) roll the whole transaction back from inside a statement. The adapter-generic transaction runner in Bun.SQL never consulted the connection's actual transaction state, so after such an auto-rollback: - if the callback didn't catch the statement error, the runner's ROLLBACK failed with "cannot rollback - no transaction is active" and that bookkeeping error replaced the real SQLITE_CONSTRAINT the caller needed to see; and - if the callback did catch it, the rest of the callback ran in autocommit (each statement individually durable), then COMMIT failed, so a rejected sql.begin() left committed rows behind. Add an optional isConnectionInTransaction() to the adapter interface and implement it for SQLite via db.inTransaction (sqlite3_get_autocommit). The transaction runner now: - rejects every subsequent tx statement with ERR_SQLITE_INVALID_TRANSACTION_STATE once SQLite has dropped the transaction, preserving atomicity; - skips the impossible COMMIT/ROLLBACK/RELEASE SAVEPOINT/ROLLBACK TO SAVEPOINT in that state; and - always rejects with the callback's original error rather than a failure from its own cleanup SQL. The hook is optional and unimplemented for Postgres/MySQL, so their behaviour is unchanged. --- src/js/bun/sql.ts | 51 +++++++++--- src/js/internal/sql/shared.ts | 1 + src/js/internal/sql/sqlite.ts | 3 + test/js/sql/sqlite-sql.test.ts | 143 +++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 10 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index c3a74582a25..00028eec4a7 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -551,6 +551,20 @@ const SQL: typeof Bun.SQL = function SQL( pool.attachConnectionCloseHandler(pooledConnection, onClose); } + // Some databases (SQLite's ON CONFLICT ROLLBACK / OR ROLLBACK / RAISE(ROLLBACK)) can roll the + // transaction back on their own mid-callback. Once that happens the connection is in autocommit + // and any further COMMIT/ROLLBACK would fail with "no transaction is active". + let needs_rollback = false; + const isConnectionInTransaction = pool.isConnectionInTransaction?.bind(pool); + function transaction_still_open() { + return isConnectionInTransaction === undefined || isConnectionInTransaction(pooledConnection); + } + function transaction_aborted_error() { + return pool.invalidTransactionStateError( + "current transaction is aborted (the database already rolled it back), commands ignored until end of transaction", + ); + } + function run_internal_transaction_sql(string) { if (state.connectionState & ReservedConnectionState.closed) { return Promise.$reject(pool.connectionClosedError()); @@ -567,6 +581,9 @@ const SQL: typeof Bun.SQL = function SQL( ) { return Promise.$reject(pool.connectionClosedError()); } + if (needs_rollback && !transaction_still_open()) { + return Promise.$reject(transaction_aborted_error()); + } if ($isArray(strings)) { // detect if is tagged template if (!$isArray((strings as unknown as TemplateStringsArray).raw)) { @@ -579,9 +596,15 @@ const SQL: typeof Bun.SQL = function SQL( return queryFromTransaction(strings, values, pooledConnection, state.queries); } transaction_sql.unsafe = (string, args = []) => { + if (needs_rollback && !transaction_still_open()) { + return Promise.$reject(transaction_aborted_error()); + } return unsafeQueryFromTransaction(string, args, pooledConnection, state.queries); }; transaction_sql.file = async (path: string, args = []) => { + if (needs_rollback && !transaction_still_open()) { + throw transaction_aborted_error(); + } return await Bun.file(path) .text() .then(text => { @@ -687,10 +710,13 @@ const SQL: typeof Bun.SQL = function SQL( for (const query of transactionQueries) { (query as Query).cancel(); } - if (BEFORE_COMMIT_OR_ROLLBACK_COMMAND) { - await run_internal_transaction_sql(BEFORE_COMMIT_OR_ROLLBACK_COMMAND); + if (transaction_still_open()) { + if (BEFORE_COMMIT_OR_ROLLBACK_COMMAND) { + await run_internal_transaction_sql(BEFORE_COMMIT_OR_ROLLBACK_COMMAND); + } + await run_internal_transaction_sql(ROLLBACK_COMMAND); } - await run_internal_transaction_sql(ROLLBACK_COMMAND); + needs_rollback = false; state.connectionState |= ReservedConnectionState.closed; }; transaction_sql[Symbol.asyncDispose] = () => transaction_sql.close(); @@ -707,6 +733,9 @@ const SQL: typeof Bun.SQL = function SQL( try { let result = await savepoint_callback(transaction_sql); + if (needs_rollback && !transaction_still_open()) { + throw transaction_aborted_error(); + } if (RELEASE_SAVEPOINT_COMMAND) { // mssql dont have release savepoint await run_internal_transaction_sql(`${RELEASE_SAVEPOINT_COMMAND} ${save_point_name}`); @@ -716,8 +745,10 @@ const SQL: typeof Bun.SQL = function SQL( } return result; } catch (err) { - if (!(state.connectionState & ReservedConnectionState.closed)) { - await run_internal_transaction_sql(`${ROLLBACK_TO_SAVEPOINT_COMMAND} ${save_point_name}`); + if (!(state.connectionState & ReservedConnectionState.closed) && transaction_still_open()) { + try { + await run_internal_transaction_sql(`${ROLLBACK_TO_SAVEPOINT_COMMAND} ${save_point_name}`); + } catch {} } throw err; } @@ -753,7 +784,6 @@ const SQL: typeof Bun.SQL = function SQL( return await promise.finally(onSavepointFinished.bind(null, promise)); }; } - let needs_rollback = false; try { await run_internal_transaction_sql(BEGIN_COMMAND); needs_rollback = true; @@ -761,6 +791,9 @@ const SQL: typeof Bun.SQL = function SQL( if ($isArray(transaction_result)) { transaction_result = await Promise.all(transaction_result); } + if (needs_rollback && !transaction_still_open()) { + throw transaction_aborted_error(); + } // at this point we dont need to rollback anymore needs_rollback = false; if (BEFORE_COMMIT_OR_ROLLBACK_COMMAND) { @@ -770,15 +803,13 @@ const SQL: typeof Bun.SQL = function SQL( return resolve(transaction_result); } catch (err) { try { - if (!(state.connectionState & ReservedConnectionState.closed) && needs_rollback) { + if (!(state.connectionState & ReservedConnectionState.closed) && needs_rollback && transaction_still_open()) { if (BEFORE_COMMIT_OR_ROLLBACK_COMMAND) { await run_internal_transaction_sql(BEFORE_COMMIT_OR_ROLLBACK_COMMAND); } await run_internal_transaction_sql(ROLLBACK_COMMAND); } - } catch (err) { - return reject(err); - } + } catch {} return reject(err); } finally { state.connectionState |= ReservedConnectionState.closed; diff --git a/src/js/internal/sql/shared.ts b/src/js/internal/sql/shared.ts index 21c781812aa..8990c517b89 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -2123,6 +2123,7 @@ export interface DatabaseAdapter { supportsReservedConnections?(): boolean; getConnectionForQuery?(pooledConnection: Connection): ConnectionHandle | null; + isConnectionInTransaction?(connection: Connection): boolean; attachConnectionCloseHandler?(connection: Connection, handler: () => void): void; detachConnectionCloseHandler?(connection: Connection, handler: () => void): void; diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 674357d7fb3..27b41896530 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -487,6 +487,9 @@ class SQLiteAdapter implements DatabaseAdapter { const accounts = await sql`SELECT * FROM accounts WHERE id = 1`; expect(accounts[0].balance).toBe(1002); }); + + describe("SQLite automatic ROLLBACK (ON CONFLICT ROLLBACK / OR ROLLBACK / RAISE(ROLLBACK))", () => { + beforeEach(async () => { + await sql`CREATE TABLE t (v TEXT PRIMARY KEY ON CONFLICT ROLLBACK)`; + await sql`CREATE TABLE log (v TEXT)`; + await sql`INSERT INTO t VALUES ('dup')`; + }); + + test("uncaught conflict: begin() rejects with the original statement error, not 'cannot rollback'", async () => { + let caught: any; + try { + await sql.begin(async tx => { + await tx`INSERT INTO t VALUES ('dup')`; + }); + } catch (e) { + caught = e; + } + expect({ code: caught?.code, message: String(caught) }).toEqual({ + code: "SQLITE_CONSTRAINT_PRIMARYKEY", + message: "SQLiteError: UNIQUE constraint failed: t.v", + }); + }); + + test("caught conflict: later statements are refused and nothing is committed", async () => { + let beginErr: any; + let stmtErr: any; + try { + await sql.begin(async tx => { + await tx`INSERT INTO log VALUES ('first')`; + try { + await tx`INSERT INTO t VALUES ('dup')`; + } catch {} + try { + await tx`INSERT INTO log VALUES ('after-auto-rollback')`; + } catch (e) { + stmtErr = e; + throw e; + } + }); + } catch (e) { + beginErr = e; + } + const durable = (await sql`SELECT v FROM log`).map((r: any) => r.v); + expect({ + stmtErrCode: stmtErr?.code, + beginErrCode: beginErr?.code, + durable, + }).toEqual({ + stmtErrCode: "ERR_SQLITE_INVALID_TRANSACTION_STATE", + beginErrCode: "ERR_SQLITE_INVALID_TRANSACTION_STATE", + durable: [], + }); + // the connection must remain usable for a fresh transaction afterwards + await sql.begin(async tx => { + await tx`INSERT INTO log VALUES ('next-tx')`; + }); + expect((await sql`SELECT v FROM log`).map((r: any) => r.v)).toEqual(["next-tx"]); + }); + + test("caught conflict where callback swallows everything: begin() still rejects, COMMIT is not attempted", async () => { + let beginErr: any; + try { + await sql.begin(async tx => { + try { + await tx`INSERT INTO t VALUES ('dup')`; + } catch {} + // every subsequent statement keeps failing; swallowing one must not let the next through + try { + await tx`INSERT INTO log VALUES ('a')`; + } catch {} + try { + await tx`INSERT INTO log VALUES ('b')`; + } catch {} + }); + } catch (e) { + beginErr = e; + } + expect({ + code: beginErr?.code, + message: String(beginErr), + durable: (await sql`SELECT v FROM log`).map((r: any) => r.v), + }).toEqual({ + code: "ERR_SQLITE_INVALID_TRANSACTION_STATE", + message: + "SQLiteError: current transaction is aborted (the database already rolled it back), commands ignored until end of transaction", + durable: [], + }); + }); + + test("INSERT OR ROLLBACK (statement-level conflict clause) behaves the same", async () => { + await sql`CREATE TABLE t2 (v TEXT PRIMARY KEY)`; + await sql`INSERT INTO t2 VALUES ('dup')`; + let caught: any; + try { + await sql.begin(async tx => { + await tx`INSERT OR ROLLBACK INTO t2 VALUES ('dup')`; + }); + } catch (e) { + caught = e; + } + expect(caught?.code).toBe("SQLITE_CONSTRAINT_PRIMARYKEY"); + }); + + test("tx.unsafe refuses to run after auto-rollback", async () => { + let stmtErr: any; + await sql + .begin(async tx => { + try { + await tx`INSERT INTO t VALUES ('dup')`; + } catch {} + await tx.unsafe("INSERT INTO log VALUES ('after-auto-rollback')"); + }) + .catch(e => (stmtErr = e)); + expect({ + code: stmtErr?.code, + durable: (await sql`SELECT v FROM log`).map((r: any) => r.v), + }).toEqual({ + code: "ERR_SQLITE_INVALID_TRANSACTION_STATE", + durable: [], + }); + }); + + test("auto-rollback inside a savepoint propagates the original error and aborts the outer transaction", async () => { + let caught: any; + try { + await sql.begin(async tx => { + await tx`INSERT INTO log VALUES ('outer')`; + await tx.savepoint(async sp => { + await sp`INSERT INTO t VALUES ('dup')`; + }); + }); + } catch (e) { + caught = e; + } + expect({ + code: caught?.code, + durable: (await sql`SELECT v FROM log`).map((r: any) => r.v), + }).toEqual({ + code: "SQLITE_CONSTRAINT_PRIMARYKEY", + durable: [], + }); + }); + }); }); describe("SQLite-specific features", () => { From 65de5273f844108a57279437adcf26a3d0ebcb29 Mon Sep 17 00:00:00 2001 From: robobun Date: Wed, 8 Jul 2026 10:44:45 +0000 Subject: [PATCH 02/10] sql(sqlite): guard tx.savepoint() and tx.file() against mid-call auto-rollback Follow-up to the auto-rollback detection: tx.savepoint() called after a swallowed ON CONFLICT ROLLBACK would issue SAVEPOINT in autocommit, which starts an implicit SQLite transaction, so the savepoint body ran and RELEASE SAVEPOINT committed it. Guard the savepoint entry point with the same needs_rollback/transaction_still_open() check used by the other tx paths, and re-check in tx.file() after the async file read resolves. --- src/js/bun/sql.ts | 13 ++++++++----- test/js/sql/sqlite-sql.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 00028eec4a7..6c7e934e143 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -605,11 +605,11 @@ const SQL: typeof Bun.SQL = function SQL( if (needs_rollback && !transaction_still_open()) { throw transaction_aborted_error(); } - return await Bun.file(path) - .text() - .then(text => { - return unsafeQueryFromTransaction(text, args, pooledConnection, state.queries); - }); + const text = await Bun.file(path).text(); + if (needs_rollback && !transaction_still_open()) { + throw transaction_aborted_error(); + } + return unsafeQueryFromTransaction(text, args, pooledConnection, state.queries); }; // reserve is allowed to be called inside transaction connection but will return a new reserved connection from the pool and will not be part of the transaction // this matchs the behavior of the postgres package @@ -767,6 +767,9 @@ const SQL: typeof Bun.SQL = function SQL( ) { throw pool.connectionClosedError(); } + if (needs_rollback && !transaction_still_open()) { + throw transaction_aborted_error(); + } if ($isCallable(name)) { savepoint_callback = name as unknown as TransactionCallback; diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 2ff6724520f..940a62d295e 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -1266,6 +1266,33 @@ describe("Transactions", () => { }); }); + test("tx.savepoint after auto-rollback is refused and its body never runs", async () => { + let bodyRan = false; + let beginErr: any; + try { + await sql.begin(async tx => { + try { + await tx`INSERT INTO t VALUES ('dup')`; + } catch {} + await tx.savepoint(async sp => { + bodyRan = true; + await sp`INSERT INTO log VALUES ('from-savepoint')`; + }); + }); + } catch (e) { + beginErr = e; + } + expect({ + bodyRan, + code: beginErr?.code, + durable: (await sql`SELECT v FROM log`).map((r: any) => r.v), + }).toEqual({ + bodyRan: false, + code: "ERR_SQLITE_INVALID_TRANSACTION_STATE", + durable: [], + }); + }); + test("auto-rollback inside a savepoint propagates the original error and aborts the outer transaction", async () => { let caught: any; try { From 54180eb35fae0085f7069268b3241b90aa9d49c9 Mon Sep 17 00:00:00 2001 From: robobun Date: Wed, 8 Jul 2026 10:50:38 +0000 Subject: [PATCH 03/10] sql(sqlite): guard close() timeout path and await savepoint result before RELEASE Mirror the transaction_still_open() guard in the close({timeout}) timer callback, and await an array result returned from a savepoint callback before the aborted-transaction check and RELEASE SAVEPOINT so the check sees the result of those queries. Also assert the triggering conflict's error code in the savepoint test. --- src/js/bun/sql.ts | 15 +++++++++------ test/js/sql/sqlite-sql.test.ts | 11 ++++++++--- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 6c7e934e143..f69101141c9 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -692,10 +692,13 @@ const SQL: typeof Bun.SQL = function SQL( for (const query of transactionQueries) { (query as Query).cancel(); } - if (BEFORE_COMMIT_OR_ROLLBACK_COMMAND) { - await run_internal_transaction_sql(BEFORE_COMMIT_OR_ROLLBACK_COMMAND); + if (transaction_still_open()) { + if (BEFORE_COMMIT_OR_ROLLBACK_COMMAND) { + await run_internal_transaction_sql(BEFORE_COMMIT_OR_ROLLBACK_COMMAND); + } + await run_internal_transaction_sql(ROLLBACK_COMMAND); } - await run_internal_transaction_sql(ROLLBACK_COMMAND); + needs_rollback = false; state.connectionState |= ReservedConnectionState.closed; resolve(); }, timeout * 1000); @@ -733,6 +736,9 @@ const SQL: typeof Bun.SQL = function SQL( try { let result = await savepoint_callback(transaction_sql); + if ($isArray(result)) { + result = await Promise.all(result); + } if (needs_rollback && !transaction_still_open()) { throw transaction_aborted_error(); } @@ -740,9 +746,6 @@ const SQL: typeof Bun.SQL = function SQL( // mssql dont have release savepoint await run_internal_transaction_sql(`${RELEASE_SAVEPOINT_COMMAND} ${save_point_name}`); } - if ($isArray(result)) { - result = await Promise.all(result); - } return result; } catch (err) { if (!(state.connectionState & ReservedConnectionState.closed) && transaction_still_open()) { diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 940a62d295e..5a201a83d6a 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -1268,12 +1268,15 @@ describe("Transactions", () => { test("tx.savepoint after auto-rollback is refused and its body never runs", async () => { let bodyRan = false; + let conflictErr: any; let beginErr: any; try { await sql.begin(async tx => { try { await tx`INSERT INTO t VALUES ('dup')`; - } catch {} + } catch (e) { + conflictErr = e; + } await tx.savepoint(async sp => { bodyRan = true; await sp`INSERT INTO log VALUES ('from-savepoint')`; @@ -1284,11 +1287,13 @@ describe("Transactions", () => { } expect({ bodyRan, - code: beginErr?.code, + conflictErrCode: conflictErr?.code, + beginErrCode: beginErr?.code, durable: (await sql`SELECT v FROM log`).map((r: any) => r.v), }).toEqual({ bodyRan: false, - code: "ERR_SQLITE_INVALID_TRANSACTION_STATE", + conflictErrCode: "SQLITE_CONSTRAINT_PRIMARYKEY", + beginErrCode: "ERR_SQLITE_INVALID_TRANSACTION_STATE", durable: [], }); }); From 69445df2b9554bc1db68ac9d662c4569a369af82 Mon Sep 17 00:00:00 2001 From: robobun Date: Wed, 8 Jul 2026 10:57:36 +0000 Subject: [PATCH 04/10] sql(sqlite): guard transaction queries at execution time, not just creation The earlier guard in transaction_sql fires when the lazy Query object is constructed. With the array-return form sql.begin(tx => [tx`a`, tx`b`]), both queries are constructed before either executes; if the first triggers ON CONFLICT ROLLBACK, the second was still executing in autocommit and being durably committed even though begin() rejected. Thread an optional preRunGuard callback through queryFromTransaction / unsafeQueryFromTransaction into queryFromTransactionHandler so the transaction-state check runs immediately before handle.run(). The reserved-connection path passes no guard, so its behaviour is unchanged. The guard is null for adapters that don't implement isConnectionInTransaction, so Postgres/MySQL see no extra work. Dropped the post-file-read re-check in tx.file() since the execution-time guard now covers it. --- src/js/bun/sql.ts | 29 ++++++++++++++++++++--------- test/js/sql/sqlite-sql.test.ts | 18 ++++++++++++++++++ 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index f69101141c9..611a3b5ce94 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -145,7 +145,7 @@ const SQL: typeof Bun.SQL = function SQL( transactionQueries.delete(query); } - function queryFromTransactionHandler(transactionQueries, query, handle, err) { + function queryFromTransactionHandler(transactionQueries, preRunGuard, query, handle, err) { const pooledConnection = this; if (err) { transactionQueries.delete(query); @@ -158,6 +158,14 @@ const SQL: typeof Bun.SQL = function SQL( return query.reject(pool.queryCancelledError()); } + if (preRunGuard !== null) { + const guardErr = preRunGuard(); + if (guardErr) { + transactionQueries.delete(query); + return query.reject(guardErr); + } + } + query.finally(onTransactionQueryDisconnected.bind(transactionQueries, query)); try { @@ -177,6 +185,7 @@ const SQL: typeof Bun.SQL = function SQL( values: any[], pooledConnection: PooledPostgresConnection, transactionQueries: Set>, + preRunGuard: (() => Error | null) | null = null, ) { try { const query = new Query( @@ -185,7 +194,7 @@ const SQL: typeof Bun.SQL = function SQL( connectionInfo.bigint ? SQLQueryFlags.allowUnsafeTransaction | SQLQueryFlags.bigint : SQLQueryFlags.allowUnsafeTransaction, - queryFromTransactionHandler.bind(pooledConnection, transactionQueries), + queryFromTransactionHandler.bind(pooledConnection, transactionQueries, preRunGuard), pool, ); @@ -201,6 +210,7 @@ const SQL: typeof Bun.SQL = function SQL( values: any[], pooledConnection: PooledPostgresConnection, transactionQueries: Set>, + preRunGuard: (() => Error | null) | null = null, ) { try { let flags = connectionInfo.bigint @@ -214,7 +224,7 @@ const SQL: typeof Bun.SQL = function SQL( strings, values, flags, - queryFromTransactionHandler.bind(pooledConnection, transactionQueries), + queryFromTransactionHandler.bind(pooledConnection, transactionQueries, preRunGuard), pool, ); transactionQueries.add(query); @@ -564,6 +574,10 @@ const SQL: typeof Bun.SQL = function SQL( "current transaction is aborted (the database already rolled it back), commands ignored until end of transaction", ); } + const preRunGuard = + isConnectionInTransaction === undefined + ? null + : () => (needs_rollback && !transaction_still_open() ? transaction_aborted_error() : null); function run_internal_transaction_sql(string) { if (state.connectionState & ReservedConnectionState.closed) { @@ -593,23 +607,20 @@ const SQL: typeof Bun.SQL = function SQL( return new SQLHelper([strings], values); } - return queryFromTransaction(strings, values, pooledConnection, state.queries); + return queryFromTransaction(strings, values, pooledConnection, state.queries, preRunGuard); } transaction_sql.unsafe = (string, args = []) => { if (needs_rollback && !transaction_still_open()) { return Promise.$reject(transaction_aborted_error()); } - return unsafeQueryFromTransaction(string, args, pooledConnection, state.queries); + return unsafeQueryFromTransaction(string, args, pooledConnection, state.queries, preRunGuard); }; transaction_sql.file = async (path: string, args = []) => { if (needs_rollback && !transaction_still_open()) { throw transaction_aborted_error(); } const text = await Bun.file(path).text(); - if (needs_rollback && !transaction_still_open()) { - throw transaction_aborted_error(); - } - return unsafeQueryFromTransaction(text, args, pooledConnection, state.queries); + return unsafeQueryFromTransaction(text, args, pooledConnection, state.queries, preRunGuard); }; // reserve is allowed to be called inside transaction connection but will return a new reserved connection from the pool and will not be part of the transaction // this matchs the behavior of the postgres package diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 5a201a83d6a..85e073ad50e 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -1233,6 +1233,24 @@ describe("Transactions", () => { }); }); + test("array-return form: queries created before the conflict are refused at execution time", async () => { + let beginErr: any; + await sql + .begin(tx => [ + tx`INSERT INTO log VALUES ('before')`, + tx`INSERT INTO t VALUES ('dup')`, + tx`INSERT INTO log VALUES ('leaked')`, + ]) + .catch(e => (beginErr = e)); + expect({ + code: beginErr?.code, + durable: (await sql`SELECT v FROM log`).map((r: any) => r.v), + }).toEqual({ + code: "SQLITE_CONSTRAINT_PRIMARYKEY", + durable: [], + }); + }); + test("INSERT OR ROLLBACK (statement-level conflict clause) behaves the same", async () => { await sql`CREATE TABLE t2 (v TEXT PRIMARY KEY)`; await sql`INSERT INTO t2 VALUES ('dup')`; From ae1f28b87004256e93f9fbfb56c8cda4729ce570 Mon Sep 17 00:00:00 2001 From: robobun Date: Wed, 8 Jul 2026 11:07:06 +0000 Subject: [PATCH 05/10] test(sql/sqlite): scale 'properly finalizes prepared statements' workload for debug builds 10k sequential INSERTs exceed the 5s per-test budget under debug+ASAN (observed ~12s on main, unrelated to the auto-rollback change). Use 1k iterations in debug builds so the file runs clean under bun bd. --- test/js/sql/sqlite-sql.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 85e073ad50e..10c0a511993 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -1,6 +1,6 @@ import { randomUUIDv7, SQL } from "bun"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; -import { tempDirWithFiles } from "harness"; +import { isDebug, tempDirWithFiles } from "harness"; import { existsSync } from "node:fs"; import { rm, stat } from "node:fs/promises"; import { join } from "node:path"; @@ -2207,7 +2207,8 @@ describe("Memory and resource management", () => { await sql`CREATE TABLE stmt_test (id INTEGER PRIMARY KEY, value TEXT)`; - const iterations = 10000; + // debug+ASAN runs ~100x slower; 10k INSERTs there exceed the per-test timeout + const iterations = isDebug ? 1000 : 10000; for (let i = 0; i < iterations; i++) { await sql`INSERT INTO stmt_test (id, value) VALUES (${i}, ${"test" + i})`; From 44010fb6b62fab82151adf57443ef300cb4ecbd9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:43:17 +0000 Subject: [PATCH 06/10] ci: retrigger From bbcd708d44add16cbb597e53c06b68f24e3eaaeb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:06:52 +0000 Subject: [PATCH 07/10] sql(sqlite): guard the SAVEPOINT command at execution time SAVEPOINT went through run_internal_transaction_sql without preRunGuard, so a concurrent unawaited conflict query could auto-rollback in the microtask between the call-time savepoint() guard and SAVEPOINT actually executing. SAVEPOINT in autocommit acts like BEGIN DEFERRED, which made db.inTransaction true again and let RELEASE SAVEPOINT durably commit the body's writes while begin() still rejected. Pass preRunGuard when issuing SAVEPOINT so the check runs at execution time like user queries already do. --- src/js/bun/sql.ts | 6 +++--- test/js/sql/sqlite-sql.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 611a3b5ce94..c1370489ddd 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -579,11 +579,11 @@ const SQL: typeof Bun.SQL = function SQL( ? null : () => (needs_rollback && !transaction_still_open() ? transaction_aborted_error() : null); - function run_internal_transaction_sql(string) { + function run_internal_transaction_sql(string, guarded = false) { if (state.connectionState & ReservedConnectionState.closed) { return Promise.$reject(pool.connectionClosedError()); } - return unsafeQueryFromTransaction(string, [], pooledConnection, state.queries); + return unsafeQueryFromTransaction(string, [], pooledConnection, state.queries, guarded ? preRunGuard : null); } function transaction_sql( strings: string | TemplateStringsArray | import("internal/sql/shared.ts").SQLHelper | Query, @@ -743,7 +743,7 @@ const SQL: typeof Bun.SQL = function SQL( transactionSavepoints.delete(savepoint_promise); } async function run_internal_savepoint(save_point_name: string, savepoint_callback: TransactionCallback) { - await run_internal_transaction_sql(`${SAVEPOINT_COMMAND} ${save_point_name}`); + await run_internal_transaction_sql(`${SAVEPOINT_COMMAND} ${save_point_name}`, true); try { let result = await savepoint_callback(transaction_sql); diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 10c0a511993..e9c76e2a0f7 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -1316,6 +1316,32 @@ describe("Transactions", () => { }); }); + test("tx.savepoint with a concurrent unawaited conflict is refused at SAVEPOINT execution time", async () => { + let bodyRan = false; + let beginErr: any; + await sql + .begin(async tx => { + // .catch() enqueues this query's execution microtask before SAVEPOINT's, without + // awaiting it; the conflict auto-rolls-back between the savepoint() call-time + // check and the SAVEPOINT command actually running. + tx`INSERT INTO t VALUES ('dup')`.catch(() => {}); + await tx.savepoint(async sp => { + bodyRan = true; + await sp`INSERT INTO log VALUES ('from-savepoint')`; + }); + }) + .catch(e => (beginErr = e)); + expect({ + bodyRan, + code: beginErr?.code, + durable: (await sql`SELECT v FROM log`).map((r: any) => r.v), + }).toEqual({ + bodyRan: false, + code: "ERR_SQLITE_INVALID_TRANSACTION_STATE", + durable: [], + }); + }); + test("auto-rollback inside a savepoint propagates the original error and aborts the outer transaction", async () => { let caught: any; try { From 3475cc0d1795b17c70057be2787ed9d4b1e2be06 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:19:22 +0000 Subject: [PATCH 08/10] sql(sqlite): don't gate SQLHelper construction on aborted-transaction state tx([...]) and tx({...}) return an inert SQLHelper value for interpolation and never touch the connection; the aborted-transaction fast-fail was running before that dispatch and turning them into rejected Promises, which became unhandled when nested as a template value. Move the check to after the SQLHelper branches, just before queryFromTransaction; the execution-time preRunGuard still covers every executing query. --- src/js/bun/sql.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index c1370489ddd..569439846a8 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -595,9 +595,6 @@ const SQL: typeof Bun.SQL = function SQL( ) { return Promise.$reject(pool.connectionClosedError()); } - if (needs_rollback && !transaction_still_open()) { - return Promise.$reject(transaction_aborted_error()); - } if ($isArray(strings)) { // detect if is tagged template if (!$isArray((strings as unknown as TemplateStringsArray).raw)) { @@ -606,6 +603,9 @@ const SQL: typeof Bun.SQL = function SQL( } else if (typeof strings === "object" && !(strings instanceof Query) && !(strings instanceof SQLHelper)) { return new SQLHelper([strings], values); } + if (needs_rollback && !transaction_still_open()) { + return Promise.$reject(transaction_aborted_error()); + } return queryFromTransaction(strings, values, pooledConnection, state.queries, preRunGuard); } From 06a5a7922654521fdffdc8de96c5802712ee83b8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:09:06 +0000 Subject: [PATCH 09/10] sql(sqlite): drop construction-time aborted check from transaction_sql transaction_sql dispatches three inert value-carrier shapes (SQLHelper for arrays/objects, notTagged Query for string identifiers) that never reach the connection. The construction-time aborted-transaction check gated each in turn, turning them into rejected Promises that became unhandled when nested as template values. The execution-time preRunGuard in queryFromTransactionHandler already rejects every query that actually executes, so the construction-time check here adds nothing for correctness; drop it rather than enumerate every value-carrier form. --- src/js/bun/sql.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 569439846a8..67e0eb50d13 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -603,9 +603,6 @@ const SQL: typeof Bun.SQL = function SQL( } else if (typeof strings === "object" && !(strings instanceof Query) && !(strings instanceof SQLHelper)) { return new SQLHelper([strings], values); } - if (needs_rollback && !transaction_still_open()) { - return Promise.$reject(transaction_aborted_error()); - } return queryFromTransaction(strings, values, pooledConnection, state.queries, preRunGuard); } From a01a6a75d1602f4473f2a7206f1520188e866ad5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:49:23 +0000 Subject: [PATCH 10/10] sql(sqlite): drop construction-time aborted check from tx.unsafe / tx.file tx.unsafe(...) results are valid nested template fragments, so the construction-time check turned them into native rejected Promises that normalizeQuery then orphaned. preRunGuard already rejects every query that actually executes; rely on it for .unsafe and .file the same way transaction_sql itself now does. --- src/js/bun/sql.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 67e0eb50d13..4a6516ee1da 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -607,15 +607,9 @@ const SQL: typeof Bun.SQL = function SQL( return queryFromTransaction(strings, values, pooledConnection, state.queries, preRunGuard); } transaction_sql.unsafe = (string, args = []) => { - if (needs_rollback && !transaction_still_open()) { - return Promise.$reject(transaction_aborted_error()); - } return unsafeQueryFromTransaction(string, args, pooledConnection, state.queries, preRunGuard); }; transaction_sql.file = async (path: string, args = []) => { - if (needs_rollback && !transaction_still_open()) { - throw transaction_aborted_error(); - } const text = await Bun.file(path).text(); return unsafeQueryFromTransaction(text, args, pooledConnection, state.queries, preRunGuard); };