Skip to content

fix(wallet-toolbox): run SQLite migrations inside knex's per-file transaction - #2

Open
E-Jacko wants to merge 1 commit into
Quaakee:codex/520-exact-signed-resumefrom
E-Jacko:fix/sqlite-migrations-transactional
Open

E-Jacko wants to merge 1 commit into
Quaakee:codex/520-exact-signed-resumefrom
E-Jacko:fix/sqlite-migrations-transactional

Conversation

@E-Jacko

@E-Jacko E-Jacko commented Sep 16, 2026

Copy link
Copy Markdown

One line of behaviour in StorageKnex.migrate(), plus comments. Targets codex/520-exact-signed-resume because that is the branch @bsv/wallet-toolbox@2.10.2-atlas.520.1 is built from — its recorded sourceCommit 8f644b64 is still this branch's head, so this applies cleanly and needs no rebase.

No action needed on bsv-blockchain#520. This is not a request to do anything now. If the artifact gets rebuilt for any reason, merging this first means the fix is simply in it.

The defect

With disableTransactions set for SQLite, every DDL statement autocommits on its own while knex records the migration in knex_migrations only after the whole file's up() resolves. A process killed mid-file — or between a file's last statement and the journal insert — leaves tables the journal never recorded. Every later migrate.latest() re-runs that file from its first statement and dies on table ... already exists, permanently. There is no in-package recovery path: the store cannot go forward, and knex will not roll back a half-applied, unjournaled file.

For a desktop wallet, a force-quit during first-run bring-up is enough to trigger it.

Reproduced against the built 2.10.2-atlas.520.1 artifact, driving the real StorageKnex.migrate():

after interrupt next launch
before proven_txs exists, journal has 0 rows failstable proven_txs already exists, and again on every retry
after proven_txs rolled back converges — 24 tables, 21 journal rows, same version

Why transactions rather than guards

Guarding each statement was the first choice and was rejected on counting:

operation up-migration sites guard available
createTable 21 hasTable
alterTable adding columns ~12 hasColumn
alterTable adding indexes ~13 none — no portable hasIndex

That is ~46 bespoke edits for a partial fix: the 13 index-adding migrations stay broken, and guards never close the journal-write window. Per-file transactions close all of it, because knex commits the migration and its journal row together and SQLite DDL is transactional.

The PRAGMA foreign_keys question

The comment this replaces cites knex#4155 — the PRAGMA is ignored when executed inside a transaction. It is still issued outside migrate.latest(), and SQLite's forced {min:1,max:1} pool means the migration transaction runs on that same connection and inherits it.

Measured rather than assumed, by reading PRAGMA foreign_keys from inside a migration under better-sqlite3:

disableTransactions=true  -> foreign_keys inside migration = 0
disableTransactions=false -> foreign_keys inside migration = 0

So the constraint the old comment protects is not lost. Not verified against the sqlite3 driver, which is not a dependency of this package.

MySQL is unaffected by construction, not merely untested: isSQLite is already false there, so disableTransactions was already false.

Verification

  • Full suite on this change: 216/216 suites, 2023 passed, 1 skipped, jest exit 0, 133s.
  • The changed line is genuinely covered — 19 test files call .migrate(), including the shared test/utils/TestUtilsWalletStorage.ts harness, against SQLite.
  • dropAllData()'s own disableTransactions: isSQLite is deliberately untouched; it is a migrate-down path used by test tooling.

One disclosure: the monorepo root declares engines.node >= 24.11 and this was built and tested under Node v22.22.0 with --config.engine-strict=false (the toolbox's own floor is >=22). Worth a re-run in a Node 24 environment before anything is re-qualified from it.

Background and rerunnable probes live in project-at-las/Atlas-Documentation under research/issue-167/. Upstream bsv-blockchain/ts-stack has not been filed yet.

…nsaction

An interrupted migration currently leaves a store that can never migrate
forward. With transactions disabled for SQLite, each DDL statement autocommits
while knex records the migration only after the whole file's up() resolves, so a
process killed mid-file — or between a file's last statement and the
knex_migrations insert — leaves tables the journal never recorded. Every later
migrate.latest() re-runs that file from its first statement and dies on
"table ... already exists" (or "duplicate column name"), permanently, with no
in-package recovery path. For a desktop wallet a force-quit during first-run
bring-up is enough.

SQLite DDL is transactional, so letting knex wrap each migration file rolls an
interrupted migration back whole and keeps the journal insert atomic with its
DDL. PRAGMA foreign_keys is still issued outside migrate.latest(): SQLite's
forced single-connection pool means the migration transaction runs on that same
connection and inherits the setting. Measured by reading PRAGMA foreign_keys
from inside a migration under better-sqlite3 — 0 (OFF) with transactions both
enabled and disabled — so the knex#4155 constraint the old comment describes is
not lost.

MySQL behaviour is unchanged: isSQLite was already false there, so
disableTransactions was already false.

Guarding each statement instead was considered and rejected: a complete guard
fix is ~46 call sites across three operation classes, knex has no portable
hasIndex for the 13 index-adding migrations, and guards never close the
journal-write window.

Verified on this change: full suite 216/216 suites, 2023 passed, 1 skipped,
jest exit 0; an interrupted first migration now rolls back and the next
migrate() converges to the same 24 tables and 21 journal rows as an
uninterrupted run.

Refs project-at-las/Atlas-Documentation#167
@E-Jacko

E-Jacko commented Sep 16, 2026

Copy link
Copy Markdown
Author

Upstream now has this too, so this branch does not have to be the long-term home:

This PR stays open because our vendored 2.10.2-atlas.520.1 is built from this branch and predates any upstream fix. If bsv-blockchain#539 lands first, this can be closed and picked up from upstream instead — whichever is less work for you.

Since opening this I also tested the case that was missing: 12 up-migrations call .alter(), which rebuilds the table on SQLite, and every test in the suite migrates a fresh database where a rebuild copies nothing. On a populated table with an FK-violating orphan row, both settings behave identically (rows preserved, orphan kept, foreign key retained in the rebuilt schema), and knex's own alter() already leaves an ambient pragma alone while transacting. Detail in bsv-blockchain#539.

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.

1 participant