fix(sqlite): correct node:sqlite driver semantics, pin the Bun toolchain, and cover the seam with tests - #726
Merged
sroussey merged 4 commits intoAug 8, 2026
Conversation
…better-sqlite3
Five behaviors of the new `node:sqlite` driver differ from the driver it
replaced in ways that are silent at the call site.
`transaction()` ignored its body's return value. An `async` body ran to its
first `await`, returned a pending promise, and the wrapper COMMITted work
the body had not finished — a later throw became an unhandled rejection with
nothing left to roll back. TypeScript does not catch it either:
`async () => {}` is assignable to `(...args: T) => void`.
`assertSyncTransactionBody` restores better-sqlite3's `TypeError` on both the
BEGIN path and the SAVEPOINT path, and attaches a no-op `catch` to the
abandoned promise so the body's own rejection does not crash the process on
top of the TypeError. `SqliteTabularStorage.withTransaction` and
`SqliteMigrationRunner` both document that they hand-roll BEGIN/COMMIT
because of that rejection.
Nesting was tracked by matching the whole `exec()` string against a BEGIN
regex. `exec("BEGIN;")` — with the trailing semicolon — missed, so a
`transaction()` inside it issued a nested BEGIN and threw "cannot start a
transaction within a transaction"; so did `exec("BEGIN; ...; COMMIT;")` and
`prepare("BEGIN").run()`. The flag could also strand itself true, since the
reset branch sat after the rethrow of a failed COMMIT. SQLite already
answers the question: `DatabaseSync.isTransaction`. The regexes, the
`#execTx` / `#txDepth` fields, the derived getter and the `close()` resets
all go; the monotonic `#savepointSeq` stays, since a never-reused savepoint
name cannot collide with one left open by a failed RELEASE. The constructor
fails loudly if the runtime predates the getter.
`enableForeignKeyConstraints` defaults to `true` in node:sqlite where
better-sqlite3 and bun:sqlite both left SQLite's own default OFF. Every
schema here was created and mutated under FK-off semantics, so existing
files legitimately hold dangling references and delete orders that were
legal when they ran; enforcing them as a side effect of a driver swap turns
historical data into `SQLITE_CONSTRAINT_FOREIGNKEY` failures at write time.
Default it off and document it as opt-in.
Constructor options were spread straight into `DatabaseSync`, which ignores
unknown keys — so a better-sqlite3 `readonly: true` opened the database
read-WRITE and `fileMustExist: true` created the missing file, both
silently. `assertKnownOptions` throws a `TypeError` for the renamed and
dropped better-sqlite3 names (with migration guidance) and for anything else
outside the supported set, as better-sqlite3 itself did.
Result rows arrive as `Object.create(null)`, and `narrowRow` mutated them in
place, so a null-prototype object reached callers as an entity:
`row.hasOwnProperty(...)` throws and `toStrictEqual` fails against the plain
objects every other backend returns. `narrowRow` now copies onto a plain
object; the storage layer keeps mutating the copy.
Also route the driver's own BEGIN / SAVEPOINT / RELEASE through the
error-translating `#exec`, so a SQLITE_BUSY on BEGIN surfaces under that
code rather than Node's `ERR_SQLITE_ERROR`, and say plainly in the
`finalize()` docs that it is a no-op on every node:sqlite shipping today
(`StatementSync` exposes no `Symbol.dispose`).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6huUY7hSkRbjun1P9HKsz
…ised
`SqliteDriver.contract.test.ts` exercises the driver directly rather than
through a storage class, one case per behavior that changed under the swap:
an async transaction body is rejected and rolls back (with an
`unhandledRejection` listener asserting the swallowed body stays swallowed);
a sync body commits, and one that throws rolls back and rethrows the
original; `exec("BEGIN;")` and `prepare("BEGIN").run()` both make a nested
`transaction()` open a SAVEPOINT, proven by rolling the outer transaction
back; a failed COMMIT does not wedge the next transaction; a nested
transaction that throws leaves the outer one intact; each legacy and unknown
constructor option throws; rows carry `Object.prototype`; UNIQUE violations
report the better-sqlite3 code with Node's on `nodeCode`; `undefined` binds
as NULL and BigInts narrow only when they round-trip; foreign keys are off
by default and enforced on request.
Two of these need a real file, which nothing else here has: the WAL journal
mode and 5s busy timeout the constructor sets only for file-backed
databases, and a contended write actually waiting out its timeout instead of
failing instantly.
Eleven of the twenty-two fail against the driver as it stood.
`SqliteTabularStorage.integration.test.ts` gains prototype and
`toStrictEqual` assertions on `get()` and on the `RETURNING *` update path —
the two places a null-prototype row reached a caller as an entity.
The sqlite-vector probe in `SqliteAiVectorStorage.integration.test.ts` wrapped
both the module resolution and the extension load in one `try {} catch {}`,
so a broken `allowExtension` skipped the whole suite and left CI green. It
now skips only when the platform has no prebuilt binary, and rethrows when
the package resolves but the extension will not load.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6huUY7hSkRbjun1P9HKsz
`engines.bun` declares `^1.4.0-canary.1` — the floor that actually has `node:sqlite` — while `packageManager` stayed at `bun@1.3.11`, which does not (`No such built-in module: node:sqlite`). `bun run test` drives both runners, so every SQLite-backed Bun suite fails on the package manager the repo itself declares; CI only escaped it because the Bun-runner jobs are commented out and the rest asked for `bun-version: latest`, which resolves to 1.3.14 and has no `node:sqlite` either. Point both fields, and every `setup-bun` step, at the same thing. No tagged Bun release carries the module yet: npm publishes nothing above 1.3.14, and `bun-v1.4.0` does not exist as a release, so a concrete pin would 404. The rolling `canary` channel — which reports itself as 1.4.0-canary.1 — is the only build that has it, and the driver was smoke-tested against it. Replace it with a concrete version once one ships. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6huUY7hSkRbjun1P9HKsz
`@workglow/sqlite`'s `./storage` no longer has a `--target=bun` build or a `bun` export condition — both runtimes drive the built-in `node:sqlite` — so the two entries that still earn a Bun build are `@workglow/util`'s `"."` and `"./worker"`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6huUY7hSkRbjun1P9HKsz
Coverage Report
File CoverageNo changed files found. |
sroussey
merged commit Aug 8, 2026
d24e2df
into
claude/better-sqlite3-node-sqlite-0ydg8g
10 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #710 — this PR targets
claude/better-sqlite3-node-sqlite-0ydg8g, notmain. Review #710 first; merge this into it (or land them together).Every finding below was reproduced by running the driver before fixing it, and each fix is pinned by a test that fails without it.
What was wrong
transaction()silently committed an async bodybetter-sqlite3 threw
TypeError("Transaction function cannot return a promise"). The replacement ignored the body's return value, so:returned without throwing, the INSERT was already COMMITted, and the throw became an unhandled rejection with nothing left to roll back. TypeScript does not catch it:
async () => {}is assignable to(...args: T) => void.Both in-repo hand-rolled transaction paths document that they avoid the wrapper because it rejects async bodies —
SqliteTabularStorage.withTransactionandSqliteMigrationRunner— so the guarantee they rely on was gone.assertSyncTransactionBodyrestores theTypeErroron the BEGIN path and in#runInSavepoint. It attaches a no-opcatchto the abandoned promise first: without that, the body's own eventual rejection surfaces as an unhandled rejection on top of the TypeError and takes the process down.Transaction nesting was detected by regex
#execTxwas set only when the wholeexec()string matched a BEGIN regex. Verified failures:db.exec("BEGIN;")(trailing semicolon) thendb.transaction(fn)()→ throws "cannot start a transaction within a transaction"; better-sqlite3 opened a SAVEPOINT.exec("BEGIN; …; COMMIT;")andprepare("BEGIN").run()were invisible the same way.#execTxstucktrue.SQLite already answers this:
DatabaseSync.isTransaction. DeletedBEGIN_RE,END_RE,#execTx,#txDepth, the#inTransactiongetter, the tracking block inexec()and theclose()resets.#savepointSeqstays — a monotonic name can never collide with a savepoint left open by a failed RELEASE. The constructor throws if the runtime predates the getter.Foreign keys silently flipped ON — behavior change for external consumers
node:sqlitedefaultsenableForeignKeyConstraintstotrue; better-sqlite3 andbun:sqliteboth left SQLite's own default OFF. Defaulted back tofalse, exposed onNodeSqliteOptionsas documented opt-in.Every schema in libs and in the three consumers (sec, builder, embarc-data) was created and mutated under FK-off semantics on both prior drivers, so existing DB files legitimately contain dangling references and delete orders that were legal when they were written. Turning enforcement on as a side effect of a driver swap converts historical data into runtime
SQLITE_CONSTRAINT_FOREIGNKEYfailures at write time in production — not at open time, where a doc note would have helped.better-sqlite3 options were silently dropped — behavior change for external consumers
readonly→readOnlywas renamed;fileMustExist/verbose/nativeBindinghave no equivalent; and...optionswas spread intoDatabaseSync, which ignores unknown keys. better-sqlite3 threwTypeError('Misspelled option …'). Verified:{readonly: true, fileMustExist: true}opened the database read-write and created the missing file, then successfullyCREATE TABLEd.assertKnownOptionsnow throws aTypeErrorfor each legacy name (with migration guidance) and for anything outside the supported set. All 21 in-repo call sites are zero- or one-arg, so this is safe in-tree; it is a breaking change for an external consumer still passing better-sqlite3 option names — which is the point, since those options were being ignored.Rows were null-prototype and escaped as entities
node:sqlitebuilds rows withObject.create(null);narrowRowmutated in place and the storage layer handed the same object back. Verified:Object.getPrototypeOf(await storage.get(key))wasnull,entity.hasOwnProperty("x")threw, andtoStrictEqualfailed — diverging SQLite from bun:sqlite, browser-WASM and Postgres, with no assertion anywhere catching it.narrowRownow returns a plain-prototype copy; the storage layer keeps mutating the copy, so no change was needed there.The pinned Bun toolchain could not load
node:sqliteengines.bunis^1.4.0-canary.1butpackageManagerwasbun@1.3.11, which has nonode:sqlite(verified:No such built-in module).bun run testdrives both runners, so SQLite-backed Bun suites fail on the repo's own declared package manager; CI escaped it only because the Bun-runner jobs are commented out.bun-version: latestwas no better — verified that npm'slatest(1.3.14) and its canary channel both lack the module. Nor could a concrete version be pinned: npm publishes nothing above 1.3.14, and thebun-v1.4.0GitHub release 404s. The rollingcanaryasset (self-reporting1.4.0-canary.1+52bf09cb1) is the only fetchable build withnode:sqlite, sopackageManageris nowbun@1.4.0-canary.1and all 17setup-bunsteps usebun-version: canary, with a comment in each workflow saying to replace it once a tagged release ships. The fixed driver was smoke-tested against that canary binary and behaves identically to Node.Docs still described a
bun:sqlitebuilddocs/technical/19-build-system.mdand18-multi-runtime-abstraction.mdstill listed@workglow/sqlite's./storageas a--target=bunentry point. The two entries that still earn a Bun build are@workglow/util's"."and"./worker".Also fixed (low)
Statement.finalize()calledstmt[Symbol.dispose]?.(), butStatementSyncexposes noSymbol.dispose— a permanent no-op, whilecanonical-api.tsdocumented it as releasing the statement. The JSDoc now says plainly that it is a no-op on node:sqlite today.exec("BEGIN" / "SAVEPOINT …" / "RELEASE …")bypassedtranslateError, so aSQLITE_BUSYon BEGIN surfaced asERR_SQLITE_ERROR. They now go through an error-translating#exechelper.Tests
packages/test/src/test/storage-tabular/SqliteDriver.contract.test.ts(new, 22 cases) drives the driver directly rather than through a storage class — async body rejected and rolled back (with anunhandledRejectionlistener asserting the swallowed rejection never surfaces); sync commit and sync throw;exec("BEGIN;")andprepare("BEGIN").run()each proven to yield a SAVEPOINT by rolling the outer transaction back; a failed COMMIT not wedging the next transaction; nested-throw isolation; every legacy/unknown option throwing; row prototypes; error-code translation;undefined→ NULL; BigInt narrowing at the safe-integer boundary; FK default off / opt-in on.Two cases use a file-backed database under
os.tmpdir()—PRAGMA journal_modeiswal,PRAGMA busy_timeoutis 5000, and a second connection's write under a heldBEGIN IMMEDIATEwaits out its timeout instead of failing instantly. Every existing SQLite test uses":memory:", so that path was entirely unexercised.11 of the 22 fail against the driver as it stood (verified by reverting
node.tsand re-running).SqliteTabularStorage.integration.test.tsgains prototype +toStrictEqualassertions onget()and on theRETURNING *update path — both fail before the fix.SqliteAiVectorStorage.integration.test.ts's extension probe wrapped module resolution and extension load in one baretry {} catch {}, so a brokenallowExtensionwould have skipped the suite and left CI green. It now skips only when the platform has no prebuilt binary, and rethrows when@sqliteai/sqlite-vectorresolves butloadExtensionfails.Rebase prerequisite (for #710, not this PR)
#710 is 26 commits behind
origin/mainand should be rebased before merge. Doing so is not free:packages/test/src/test/util/BunExportConditions.test.tsexists onmainbut not on Migrate SQLite driver from better-sqlite3 to node:sqlite #710's base. It asserts a three-entryEXPECTED_BUN_CONDITIONSset, which Migrate SQLite driver from better-sqlite3 to node:sqlite #710's removal of@workglow/sqlite's./storagebuncondition breaks. The rebase needsEXPECTED_BUN_CONDITIONSupdated to the two@workglow/utilentries and the twosqliteExportsassertions removed. That test also names the two docs files this PR edits as prose that must change with the condition set — already done here.mainadds.github/workflows/nightly-bun-parity.yml, which has its ownbun-version: latest(line 45 on main). It needs the samecanarypin this PR applies to the three workflow files that exist on Migrate SQLite driver from better-sqlite3 to node:sqlite #710's base.main'sscripts/test.tshas discovered sections and--check-sections; Migrate SQLite driver from better-sqlite3 to node:sqlite #710's base still has the hand-enumerated list (nostorage-tabularsection, no--check-sections). After the rebase,bun scripts/test.ts --check-sectionsshould be run to confirm the new test file is reachable — it lives in the already-coveredstorage-tabulardirectory, so no section change is expected.I did not rebase #710, per instruction.
Verification
Run on Node v22.22.2 (
node:sqlitepresent, experimental warning) withbun run use-source, in an isolated worktree.bunx vitest run …/SqliteDriver.contract.test.tsnode.tsreverted)bunx vitest run …/SqliteTabularStorage.integration.test.tsnode.tsreverted)bunx vitest runover the 16 SQLite-touching filesbun scripts/test.ts storage vitest(63 files)bun scripts/test.ts util vitest(49 files)bun run build:types(37 packages, incl.@workglow/sqliteand@workglow/test)bunx eslint+bunx prettieron changed files1.4.0-canary.1+52bf09cb1smoke test of the driverObject.prototyperows,foreign_keys = 0, legacy option rejectedNotes on the two Postgres failures:
PostgresTabularStorage.integration.test.tsandPostgresTabular.smoke.test.tstime out under full-section load, andScopedTabularStoragePostgres.integration.test.tsfails itsbeforeAllhook. Running those three files in isolation against the unmodified branch and against this branch gives byte-identical results (1 file failed, 2 passed, 159 passed / 3 skipped in both), so they are pre-existing PGlite flakiness, not a regression here.bun scripts/test.ts storage-tabular vitestand--check-sectionsaremain-only features; on #710's base the equivalents are thestoragesection and no--check-sections, which is what was run.🤖 Generated with Claude Code
https://claude.ai/code/session_01K6huUY7hSkRbjun1P9HKsz
Generated by Claude Code