Skip to content

sql(sqlite): detect SQLite's automatic ROLLBACK inside sql.begin()#33749

Open
robobun wants to merge 7 commits into
mainfrom
claude/sql-sqlite-autorollback
Open

sql(sqlite): detect SQLite's automatic ROLLBACK inside sql.begin()#33749
robobun wants to merge 7 commits into
mainfrom
claude/sql-sqlite-autorollback

Conversation

@robobun

@robobun robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Reproduction

const sql = new Bun.SQL({ adapter: "sqlite", filename: ":memory:" });
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')`;

// (A) callback tolerates the conflict
await sql.begin(async tx => {
  await tx`insert into log values ('first')`;
  try { await tx`insert into t values ('dup')`; } catch {}   // SQLite auto-ROLLBACK
  await tx`insert into log values ('kept')`;                  // runs in AUTOCOMMIT
}).catch(e => console.log("(A)", String(e)));
console.log((await sql`select v from log`).map(r => r.v));
// before: (A) SQLiteError: cannot commit - no transaction is active
//         [ "kept" ]   <- a REJECTED begin() committed a row

// (B) callback does not catch
await sql.begin(async tx => { await tx`insert into t values ('dup')`; })
  .catch(e => console.log("(B)", e.code, String(e)));
// before: (B) SQLITE_ERROR SQLiteError: cannot rollback - no transaction is active
//         <- real SQLITE_CONSTRAINT_PRIMARYKEY is gone

Cause

ON CONFLICT ROLLBACK, INSERT OR ROLLBACK, and RAISE(ROLLBACK) make SQLite roll the whole transaction back from inside a statement. The adapter-generic runner in onTransactionConnected (src/js/bun/sql.ts) never checked the connection's real transaction state, so after an auto-rollback:

  • (B) the runner issued ROLLBACK, which failed with "cannot rollback - no transaction is active"; that error then shadowed the callback's real error because the inner catch rebound err;
  • (A) the rest of the callback ran in autocommit, each statement individually durable, and COMMIT then failed, so a rejected sql.begin() left committed rows behind.

bun:sqlite's own db.transaction() already guards on db.inTransaction for exactly this case; the Bun.SQL runner did not.

Fix

Add an optional isConnectionInTransaction(connection) hook to the adapter interface and implement it for SQLite via db.inTransaction (sqlite3_get_autocommit). In the runner:

  • once needs_rollback is set and the adapter reports the transaction is gone, every further tx\...`/tx.unsafe()/tx.file()rejects withERR_SQLITE_INVALID_TRANSACTION_STATE` instead of executing in autocommit;
  • the pre-COMMIT / pre-RELEASE SAVEPOINT paths make the same check so a callback that swallows everything still fails;
  • ROLLBACK and ROLLBACK TO SAVEPOINT are skipped when the adapter says there is nothing to roll back;
  • cleanup-SQL failures no longer replace the caller's original error.

The hook is optional; Postgres and MySQL don't implement it, so their behaviour is unchanged. Related but distinct from #33748 (COMMIT fails with SQLITE_BUSY and leaves the transaction open); the two fixes are complementary.

Verification

bun bd test test/js/sql/sqlite-sql.test.ts -t "automatic ROLLBACK"
  6 pass / 0 fail
USE_SYSTEM_BUN=1 bun test test/js/sql/sqlite-sql.test.ts -t "automatic ROLLBACK"
  0 pass / 6 fail

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.
@github-actions github-actions Bot added the claude label Jul 8, 2026
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:54 AM PT - Jul 8th, 2026

@robobun, your commit bbcd708 is still building in Build #70486, but has 3 failures so far (All Failures):

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a411496c-4744-4c44-9fa6-82a4e34c8e79

📥 Commits

Reviewing files that changed from the base of the PR and between 65de527 and bbcd708.

📒 Files selected for processing (2)
  • src/js/bun/sql.ts
  • test/js/sql/sqlite-sql.test.ts

Walkthrough

Adds SQLite transaction auto-rollback detection to the SQL runtime. Transaction execution now checks connection state after conflicts, skips or rejects work after rollback, and updates cleanup paths accordingly. The adapter interface exposes transaction-state checking, and tests cover rollback-on-conflict cases.

Changes

Auto-rollback detection and handling

Layer / File(s) Summary
Adapter capability contract and SQLite implementation
src/js/internal/sql/shared.ts, src/js/internal/sql/sqlite.ts
DatabaseAdapter gains an optional isConnectionInTransaction? method; SQLiteAdapter implements it by returning the connection's inTransaction flag.
Transaction abort state tracking
src/js/bun/sql.ts
Introduces needs_rollback, transaction_still_open(), and transaction_aborted_error() in the transaction execution flow.
Guard query and savepoint execution
src/js/bun/sql.ts
Rejects transaction_sql, transaction_sql.unsafe, and transaction_sql.file after auto-rollback, and adds aborted-transaction checks plus rollback-to-savepoint gating in savepoint execution.
Conditional rollback and post-callback validation
src/js/bun/sql.ts
Updates transaction_sql.close, post-callback validation, and catch-path rollback to consult transaction_still_open() before issuing COMMIT or ROLLBACK commands.
SQLite automatic rollback test suite
test/js/sql/sqlite-sql.test.ts
Adds tests for uncaught, caught, swallowed, and savepoint-triggered rollback-on-conflict cases, plus statement-level rollback clauses, post-rollback transaction behavior, and debug-dependent iteration scaling.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: detecting SQLite automatic rollback during sql.begin().
Description check ✅ Passed The description covers the problem, fix, reproduction, and verification, matching the template's required information.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/js/bun/sql.ts (3)

604-612: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Re-check transaction state after the async file read.

tx.file() checks before Bun.file(path).text(), but another in-flight statement can auto-rollback while the file is being read; the .then then still enqueues unsafeQueryFromTransaction, potentially running file SQL outside the aborted transaction.

Proposed fix
 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 => {
-      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);
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/bun/sql.ts` around lines 604 - 612, Re-check the transaction state in
transaction_sql.file after Bun.file(path).text() resolves, because the initial
needs_rollback/transaction_still_open() guard can become stale while the file is
being read. Update the transaction_sql.file path so the async continuation
verifies the transaction is still open before calling
unsafeQueryFromTransaction, and abort with transaction_aborted_error() if the
transaction was auto-rolled back during the file read.

731-738: 🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Reject savepoint() once the transaction has auto-rolled back.
A swallowed OR ROLLBACK can leave SQLite in autocommit, and this path still issues SAVEPOINT before checking needs_rollback. That can run the savepoint body and let later writes be committed by the outer transaction. Add a regression covering begin() → swallowed auto-rollback → tx.savepoint(...); the body should not run and nothing should persist.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/bun/sql.ts` around lines 731 - 738, The savepoint flow in
run_internal_savepoint currently creates the SAVEPOINT before checking whether
the surrounding transaction has already auto-rolled back, which can let
tx.savepoint(...) execute inside an autocommit state after a swallowed OR
ROLLBACK. Move the rollback-state guard to the start of run_internal_savepoint
(before run_internal_transaction_sql and before invoking savepoint_callback) so
savepoints are rejected once needs_rollback indicates the transaction is no
longer usable, and add a regression test around begin() → swallowed
auto-rollback → tx.savepoint(...) to verify the body never runs and no writes
persist.

794-802: 🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Keep needs_rollback armed until after COMMIT. Clearing it before BEFORE_COMMIT_OR_ROLLBACK_COMMAND / COMMIT_COMMAND lets either failure skip the rollback path and return a connection that may still be inside a failed transaction. Move the reset to after COMMIT. src/js/bun/sql.ts:794-810

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/bun/sql.ts` around lines 794 - 802, Keep needs_rollback set until the
transaction has fully committed, since clearing it before
BEFORE_COMMIT_OR_ROLLBACK_COMMAND and COMMIT_COMMAND can bypass rollback
handling if either call fails. Update the commit flow in the transaction logic
around transaction_aborted_error, run_internal_transaction_sql, and
COMMIT_COMMAND so needs_rollback is only reset after COMMIT completes
successfully, preserving rollback safety if the pre-commit hook or commit itself
throws.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/js/bun/sql.ts`:
- Around line 604-612: Re-check the transaction state in transaction_sql.file
after Bun.file(path).text() resolves, because the initial
needs_rollback/transaction_still_open() guard can become stale while the file is
being read. Update the transaction_sql.file path so the async continuation
verifies the transaction is still open before calling
unsafeQueryFromTransaction, and abort with transaction_aborted_error() if the
transaction was auto-rolled back during the file read.
- Around line 731-738: The savepoint flow in run_internal_savepoint currently
creates the SAVEPOINT before checking whether the surrounding transaction has
already auto-rolled back, which can let tx.savepoint(...) execute inside an
autocommit state after a swallowed OR ROLLBACK. Move the rollback-state guard to
the start of run_internal_savepoint (before run_internal_transaction_sql and
before invoking savepoint_callback) so savepoints are rejected once
needs_rollback indicates the transaction is no longer usable, and add a
regression test around begin() → swallowed auto-rollback → tx.savepoint(...) to
verify the body never runs and no writes persist.
- Around line 794-802: Keep needs_rollback set until the transaction has fully
committed, since clearing it before BEFORE_COMMIT_OR_ROLLBACK_COMMAND and
COMMIT_COMMAND can bypass rollback handling if either call fails. Update the
commit flow in the transaction logic around transaction_aborted_error,
run_internal_transaction_sql, and COMMIT_COMMAND so needs_rollback is only reset
after COMMIT completes successfully, preserving rollback safety if the
pre-commit hook or commit itself throws.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 802eff94-9c34-4911-9e3a-379ea4b4d326

📥 Commits

Reviewing files that changed from the base of the PR and between 332f744 and a299c33.

📒 Files selected for processing (4)
  • src/js/bun/sql.ts
  • src/js/internal/sql/shared.ts
  • src/js/internal/sql/sqlite.ts
  • test/js/sql/sqlite-sql.test.ts

…-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.
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review in 65de527:

  • tx.file() re-check after the async read: applied. The window only matters if the caller kicks off tx.file() before awaiting the statement that auto-rolls-back, but the second check is cheap and closes it.
  • tx.savepoint() after a swallowed auto-rollback: good catch, this one was real. SAVEPOINT in autocommit starts an implicit SQLite transaction, so the body ran and RELEASE SAVEPOINT committed it even though begin() rejected. Guarded the savepoint entry point and added a test that asserts bodyRan === false and durable === [].
  • needs_rollback cleared before COMMIT: that's pre-existing behaviour this PR doesn't touch, and it's exactly what sql: roll back when COMMIT itself fails (SQLite SQLITE_BUSY left tx open) #33748 fixes (COMMIT fails with SQLITE_BUSY and leaves the transaction open). Leaving it to that PR so the two changes don't overlap.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/js/bun/sql.ts (2)

713-719: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Apply the same rollback guard to timeout cleanup.

The immediate close() path skips rollback when SQLite has already auto-rolled back, but the timeout callback above still unconditionally runs BEFORE_COMMIT_OR_ROLLBACK_COMMAND and ROLLBACK_COMMAND. That parallel cleanup path can still hit “no transaction is active” and leave the timeout cleanup in a rejected async callback. As per coding guidelines, “Fix the whole class in the same PR” across parallel cleanup paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/bun/sql.ts` around lines 713 - 719, The timeout cleanup path in
`run_internal_transaction_sql` still unconditionally issues the pre-rollback
hook and `ROLLBACK_COMMAND`, unlike the immediate `close()` path that first
checks `transaction_still_open()`. Add the same guard to the timeout callback so
it only runs `BEFORE_COMMIT_OR_ROLLBACK_COMMAND` and `ROLLBACK_COMMAND` when the
transaction is still open, preventing “no transaction is active” rejections in
the parallel cleanup path.

Source: Coding guidelines


735-745: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Validate savepoint state after resolving array results.

Line 736 checks for SQLite auto-rollback before Promise.all(result), so a savepoint callback that returns an array of transaction queries can auto-rollback while those queries settle and still proceed toward RELEASE SAVEPOINT. Mirror the outer transaction flow by awaiting array results before the aborted-transaction check and release.

Proposed fix
         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();
         }
         if (RELEASE_SAVEPOINT_COMMAND) {
           // 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;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/bun/sql.ts` around lines 735 - 745, The savepoint flow in the
transaction handler is checking for SQLite auto-rollback before resolving array
results, which can let a savepoint callback return transaction queries and still
proceed toward releasing the savepoint after rollback. In the
transaction/savepoint logic around savepoint_callback, transaction_still_open,
and RELEASE_SAVEPOINT_COMMAND, await Promise.all(result) first when
$isArray(result) is true, then perform the needs_rollback and
transaction_still_open check, and only release the savepoint if the transaction
is still valid.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/sql/sqlite-sql.test.ts`:
- Around line 1269-1294: The test for tx.savepoint after auto-rollback should
also assert the inner INSERT failure, not just the outer begin error. Update the
anonymous async callback in this sqlite-sql test to capture the error from the
triggering tx`INSERT INTO t VALUES ('dup')` and assert its code before
continuing, so the case is verified to follow the intended ON CONFLICT ROLLBACK
path. Keep the existing beginErr and bodyRan checks, but add an inner error
variable in the same test block to validate the statement-level failure
alongside tx.savepoint.

---

Outside diff comments:
In `@src/js/bun/sql.ts`:
- Around line 713-719: The timeout cleanup path in
`run_internal_transaction_sql` still unconditionally issues the pre-rollback
hook and `ROLLBACK_COMMAND`, unlike the immediate `close()` path that first
checks `transaction_still_open()`. Add the same guard to the timeout callback so
it only runs `BEFORE_COMMIT_OR_ROLLBACK_COMMAND` and `ROLLBACK_COMMAND` when the
transaction is still open, preventing “no transaction is active” rejections in
the parallel cleanup path.
- Around line 735-745: The savepoint flow in the transaction handler is checking
for SQLite auto-rollback before resolving array results, which can let a
savepoint callback return transaction queries and still proceed toward releasing
the savepoint after rollback. In the transaction/savepoint logic around
savepoint_callback, transaction_still_open, and RELEASE_SAVEPOINT_COMMAND, await
Promise.all(result) first when $isArray(result) is true, then perform the
needs_rollback and transaction_still_open check, and only release the savepoint
if the transaction is still valid.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2cfcfe25-46fa-483f-8ba9-eaeb545b1487

📥 Commits

Reviewing files that changed from the base of the PR and between a299c33 and 65de527.

📒 Files selected for processing (2)
  • src/js/bun/sql.ts
  • test/js/sql/sqlite-sql.test.ts

Comment thread test/js/sql/sqlite-sql.test.ts
…fore 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.
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Applied the second-round review in 54180eb:

  • close({timeout}) timer path: wrapped in the same transaction_still_open() guard as the immediate path. SQLite never reaches this branch (queries complete synchronously), but keeping the two cleanup paths consistent is the right call.
  • Savepoint result ordering: moved await Promise.all(result) before the aborted-transaction check and before RELEASE SAVEPOINT, matching how the outer transaction flow already orders it. The check now sees the state after the returned queries settle.
  • Savepoint test: now asserts conflictErrCode: "SQLITE_CONSTRAINT_PRIMARYKEY" for the triggering statement.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/js/bun/sql.ts:733-738tx.savepoint() is missing the same needs_rollback && !transaction_still_open() guard that was added to tx\...`, tx.unsafe(), and tx.file(). After a caught auto-rollback, SAVEPOINT s0runs in autocommit and (per SQLite docs) acts likeBEGIN DEFERRED, so db.inTransactionflips back totrue, every subsequent guard passes, and RELEASE SAVEPOINT s0acts likeCOMMIT— durably committing the savepoint callback's writes even thoughsql.begin()then rejects. Add the guard at the top ofrun_internal_savepoint(or intransaction_sql.savepoint) before issuing SAVEPOINT`.

    Extended reasoning...

    What the bug is

    The PR guards three of the four user-callable tx.* entry points against SQLite's automatic rollback: transaction_sql (tagged-template), transaction_sql.unsafe, and transaction_sql.file all now check needs_rollback && !transaction_still_open() before running SQL. The fourth sibling — transaction_sql.savepoint / run_internal_savepoint — has no such guard before it issues SAVEPOINT. It only checks state.connectionState, and run_internal_transaction_sql (which actually sends SAVEPOINT s0) also has no guard. The new check inside run_internal_savepoint at line 736 runs only after the savepoint callback returns.

    The specific code path

    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')`;
    
    await sql.begin(async tx => {
      try { await tx`INSERT INTO t VALUES ('dup')`; } catch {}   // SQLite auto-ROLLBACK -> autocommit
      await tx.savepoint(async sp => {
        await sp`INSERT INTO log VALUES ('leaked')`;
      });
    });

    Why existing code doesn't prevent it

    Per the SQLite docs (lang_savepoint.html):

    When a SAVEPOINT is the outer-most savepoint and it is not within a BEGIN...COMMIT then the behavior is the same as BEGIN DEFERRED TRANSACTION.

    If the RELEASE command releases the outermost savepoint, so that the transaction savepoint stack is empty, then RELEASE is equivalent to COMMIT.

    So once SAVEPOINT s0 runs in autocommit, sqlite3_get_autocommit() returns 0 again and connection.inTransaction === true. Every guard the PR added keys on !transaction_still_open(), and transaction_still_open() is now true — so the guard on sp\...`` (line 584), the post-callback guard at line 736, and the outer post-callback guard at line 794 are all defeated for the duration of the savepoint.

    Step-by-step proof

    1. BEGIN runs; needs_rollback = true; db.inTransaction = true.
    2. INSERT INTO t VALUES ('dup') fails with SQLITE_CONSTRAINT_PRIMARYKEY; SQLite auto-rolls back the whole transaction; db.inTransaction = false. The user catches and continues.
    3. tx.savepoint(cb)transaction_sql.savepoint checks only connectionState (still acceptQueries, not closed) → run_internal_savepoint('s0', cb).
    4. First line of run_internal_savepoint: run_internal_transaction_sql('SAVEPOINT s0'). run_internal_transaction_sql only checks closed, so it executes. SQLite is in autocommit, so SAVEPOINT s0 acts like BEGIN DEFERRED — a new transaction opens; db.inTransaction = true.
    5. Inside the callback, sp is transaction_sql. Its guard evaluates needs_rollback && !transaction_still_open()true && !truefalse. INSERT INTO log VALUES ('leaked') executes inside the fresh transaction.
    6. Callback returns. Line 736 guard: true && !truefalse, no throw. RELEASE SAVEPOINT s0 runs; releasing the outermost savepoint is equivalent to COMMIT. 'leaked' is now durable on disk; db.inTransaction = false.
    7. run_internal_savepoint returns; back in the outer runner, line 794's guard evaluates true && !falsetrue, so it throws transaction_aborted_error() and sql.begin() rejects with ERR_SQLITE_INVALID_TRANSACTION_STATE.

    Net result: sql.begin() rejects, yet log durably contains 'leaked' — exactly case (A) from the PR description ("a rejected sql.begin() left committed rows behind"), reached via the sibling entry point the PR didn't guard.

    Impact

    This is the same data-integrity failure the PR sets out to eliminate. It's reachable via a documented public API (tx.savepoint) using the same trigger (ON CONFLICT ROLLBACK / OR ROLLBACK / RAISE(ROLLBACK)) the PR already tests.

    How to fix

    Add the same one-line guard before issuing SAVEPOINT, e.g. at the top of run_internal_savepoint:

    async function run_internal_savepoint(save_point_name, savepoint_callback) {
      if (needs_rollback && !transaction_still_open()) {
        throw transaction_aborted_error();
      }
      await run_internal_transaction_sql(`${SAVEPOINT_COMMAND} ${save_point_name}`);
      ...
    }

    (or equivalently in transaction_sql.savepoint before calling run_internal_savepoint). Worth adding a test alongside the existing "auto-rollback inside a savepoint" case that catches the auto-rollback before calling tx.savepoint() and asserts log stays empty.

    On the refutation

    One verifier marked bug_004 REFUTED as a duplicate of bug_002 — that's a dedup note, not a disagreement on the substance. All seven substantive verifier passes confirmed the trace and the SQLite semantics it relies on.

Comment thread src/js/bun/sql.ts
Comment thread src/js/bun/sql.ts
…eation

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.
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

On the three claude[bot] findings:

  • Execution-time vs creation-time guard (src/js/bun/sql.ts:586): valid, fixed in 69445df. The lazy Query was guarded at construction, so sql.begin(tx => [tx\conflict`, tx`insert`])built both queries while the transaction was open and the second ran in autocommit after the first auto-rolled-back. Threaded apreRunGuardcallback intoqueryFromTransactionHandlerso the check runs right beforehandle.run(). The reserved-connection path passes null; adapters without isConnectionInTransactiongetnull too, so no extra work for Postgres/MySQL. Added a test for the array-return form (durable: ["leaked"]on main,[]` after).
  • tx.savepoint() missing guard (src/js/bun/sql.ts:733): already fixed in 65de527 (review was against an earlier commit). transaction_sql.savepoint now checks needs_rollback && !transaction_still_open() before issuing SAVEPOINT, with a test asserting bodyRan === false and durable === [].
  • close({timeout}) timer callback unguarded (src/js/bun/sql.ts:719): already fixed in 54180eb (same transaction_still_open() wrap as the non-timeout branch).

…load 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.
Comment thread src/js/bun/sql.ts
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.
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI status

The diff is green for everything it touches. test/js/sql/sqlite-sql.test.ts passes on every lane (240/240 locally under both debug+ASAN and release). The remaining red is unrelated infrastructure flake on lanes this change does not reach:

build failure lane relation to this PR
#70472 test/regression/issue/26030.test.ts alpine x64 MySQL docker container health-check timeout
#70486 test/regression/issue/26030.test.ts alpine x64-baseline same MySQL docker health-check timeout
#70486 test/js/bun/http/proxy-stress-concurrent.test.ts darwin 14 x64 1/1200 HTTPS proxy requests failed under stress
#70486 test/js/bun/terminal/terminal.test.ts darwin 14 x64 PTY subprocess attach 90s timeout

This PR only edits src/js/bun/sql.ts, src/js/internal/sql/{shared,sqlite}.ts, and test/js/sql/sqlite-sql.test.ts; none of the above touch the SQLite adapter or Bun.SQL. Ready for review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant