fix(wallet-toolbox): run SQLite migrations inside knex's per-file transaction - #2
Conversation
…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
|
Upstream now has this too, so this branch does not have to be the long-term home:
This PR stays open because our vendored Since opening this I also tested the case that was missing: 12 up-migrations call |
One line of behaviour in
StorageKnex.migrate(), plus comments. Targetscodex/520-exact-signed-resumebecause that is the branch@bsv/wallet-toolbox@2.10.2-atlas.520.1is built from — its recordedsourceCommit8f644b64is 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
disableTransactionsset for SQLite, every DDL statement autocommits on its own while knex records the migration inknex_migrationsonly after the whole file'sup()resolves. A process killed mid-file — or between a file's last statement and the journal insert — leaves tables the journal never recorded. Every latermigrate.latest()re-runs that file from its first statement and dies ontable ... 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.1artifact, driving the realStorageKnex.migrate():proven_txsexists, journal has 0 rowstable proven_txs already exists, and again on every retryproven_txsrolled backWhy transactions rather than guards
Guarding each statement was the first choice and was rejected on counting:
createTablehasTablealterTableadding columnshasColumnalterTableadding indexeshasIndexThat 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_keysquestionThe 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_keysfrom inside a migration underbetter-sqlite3:So the constraint the old comment protects is not lost. Not verified against the
sqlite3driver, which is not a dependency of this package.MySQL is unaffected by construction, not merely untested:
isSQLiteis alreadyfalsethere, sodisableTransactionswas alreadyfalse.Verification
.migrate(), including the sharedtest/utils/TestUtilsWalletStorage.tsharness, against SQLite.dropAllData()'s owndisableTransactions: isSQLiteis deliberately untouched; it is a migrate-down path used by test tooling.One disclosure: the monorepo root declares
engines.node >= 24.11and 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-Documentationunderresearch/issue-167/. Upstreambsv-blockchain/ts-stackhas not been filed yet.