Skip to content

fix(kernel-store): ask SQLite whether a transaction is open - #1089

Open
sirtimid wants to merge 4 commits into
mainfrom
sirtimid/wasm-live-in-transaction
Open

sirtimid wants to merge 4 commits into
mainfrom
sirtimid/wasm-live-in-transaction

Conversation

@sirtimid

@sirtimid sirtimid commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

The wasm driver kept its own _inTx flag and set it whenever it issued a BEGIN, COMMIT or ROLLBACK. SQLite does not only end transactions when asked: after SQLITE_FULL, SQLITE_IOERR or SQLITE_BUSY it ends the current one itself, along with every savepoint in it. The flag then latched true. The next createSavepoint saw a transaction it thought was already open and skipped its BEGIN; the matching releaseSavepoint threw cannot commit - no transaction is active; and the browser kernel store went on refusing every later write to a database that was perfectly healthy.

inTransaction is now a getter over sqlite3_get_autocommit. The wasm build binds that function but does not declare it, hence the narrow AutocommitCapi cast — and the check at init, before the database is opened, that it really is there.

Trusting SQLite costs an invariant the cached flag used to carry for free: _inTx === false implied the savepoint stack was empty, because the same code cleared both. It does not any more. When SQLite ends a transaction under an open savepoint, beginIfNeeded opens a fresh one while _spStack still names a savepoint from the old one, and commitIfNeeded — which declines when the stack is non-empty, on the grounds that a savepoint owner will commit — defers to an owner that no longer exists. The write sits in a transaction nobody closes, and every later write joins it. So beginIfNeeded now clears the stack when it opens a transaction. The Node driver needed the same fix: it has read better-sqlite3's live db.inTransaction all along, so it has had this hole since the savepoint stack was introduced.

Changes

  • Database.inTransaction replaces _inTx: a getter installed in initDB over sqlite3_get_autocommit, guarded for a closed connection, with a hard failure at init if the wasm build does not bind the function.
  • beginIfNeeded clears the savepoint stack when it opens a transaction, in both the wasm and the Node driver.

Testing

wasm.transactions.test.ts is new and deliberately carries no vi.mock, so it runs against the real wasm SQLite build. Its two regression guards both fail on main:

  • takes the next crank in a transaction of its own — SQLite ends the transaction, the next savepoint has to open its own.
  • commits a write made after SQLite ends the transaction itself — asserts no transaction is left open afterwards, which is what fails when the savepoint stack is stale.

reports no transaction once the database is closed pins the pointer guard: without it sqlite3_get_autocommit(undefined) returns 0 and a closed database reports a transaction open forever.

The mocked suites needed their databases to stop lying. In both wasm.test.ts and nodejs.test.ts, prepare now returns distinct statement mocks for BEGIN, COMMIT and ABORT, and stepping one of those is what moves the transaction flag — as it does in SQLite. Assertions that read the driver's own bookkeeping now read which statement it ran. That conversion is most of this diff, and it repaired three assertions that could not fail: two not.toHaveBeenCalledWith(objectContaining({ sql })) against a zero-argument step(), and one test that ended in a bare assignment where an expect was meant. nodejs.test.ts gains drops savepoints SQLite discarded with the transaction for the Node half of the fix, and wasm.test.ts gains reports the write failure, not a rollback with nothing to undo, which pins the if (db.inTransaction) guard in rollbackIfNeeded — without it, a rollback attempted after SQLite already ended the transaction throws and replaces the real "disk is full" with "cannot rollback".

Every fix above was mutation-checked by reverting it alone and confirming the named test fails. kernel-store and ocap-kernel suites are green locally, as are the builds; @ocap/kernel-test's crank-rollback, vatstore and garbage-collection run green against a rebuilt dist. Its remote-comms test crashes the Node worker on this machine identically on origin/main, so it is not from this change.

Resolves #1074

🤖 Generated with Claude Code


Note

Medium Risk
Changes core persistence transaction/savepoint logic in both wasm and Node drivers; incorrect behavior could lose or strand writes, though the fix targets a known data-loss bug in the browser store.

Overview
Fixes kernel-store SQLite transaction bookkeeping when SQLite ends a transaction on its own (disk full, I/O error, busy) instead of only on explicit BEGIN/COMMIT/ROLLBACK.

The wasm driver drops the cached _inTx flag for a live inTransaction getter backed by sqlite3_get_autocommit, with init failing if that C API is missing. beginIfNeeded now clears the savepoint stack whenever it starts a new transaction so stale savepoint names cannot block commits; the Node driver gets the same stack clear while it already relied on better-sqlite3’s inTransaction.

Tests add unmocked wasm integration coverage for savepoint/transaction recovery, tighten mocks so BEGIN/COMMIT/ABORT drive transaction state, and add regressions for post-recovery writes and error reporting. CHANGELOG records the persistence fixes.

Reviewed by Cursor Bugbot for commit 9658e48. Bugbot is set up for automated code reviews on this repo. Configure here.

sirtimid and others added 3 commits September 15, 2026 19:12
The wasm driver tracked the transaction itself in `_inTx`. SQLite ends a
transaction on its own after SQLITE_FULL, SQLITE_IOERR or SQLITE_BUSY, and the
flag then latched: the next savepoint skipped its `BEGIN`, and releasing it
threw "cannot commit - no transaction is active". `inTransaction` now reads
`sqlite3_get_autocommit`, which the wasm build binds but does not type — hence
the cast, and the check at init that it is there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Without the `pointer` guard, `sqlite3_get_autocommit(undefined)` returns 0 and a
closed connection reports a transaction open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…egins

Review follow-up. Reading `inTransaction` from SQLite drops the invariant the
cached flag carried, that no transaction implies an empty savepoint stack. When
SQLite ends a transaction itself, the stale stack makes `commitIfNeeded` defer
to a savepoint owner that no longer exists, and the next write sits in a
transaction nobody closes. The Node driver has read better-sqlite3's live
`inTransaction` all along and had the same hole; both are fixed, and both mocks
now move the flag the way SQLite does.

The capi check moves above the open so a build without `sqlite3_get_autocommit`
does not leak the handle, and `inTransaction` is no longer `configurable`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sirtimid
sirtimid requested a review from a team as a code owner September 15, 2026 17:35
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The wasm driver's sqlite3_get_autocommit binding is asserted by cast, not checked

1 participant