Skip to content

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

Draft
E-Jacko wants to merge 1 commit into
bsv-blockchain:mainfrom
E-Jacko:fix/538-sqlite-migration-atomicity
Draft

E-Jacko wants to merge 1 commit into
bsv-blockchain:mainfrom
E-Jacko:fix/538-sqlite-migration-atomicity

Conversation

@E-Jacko

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

Copy link
Copy Markdown
Contributor

Opened as a draft: I cannot complete local validation on the declared root toolchain (see Verification). Hosted CI on this head is the real gate.

Program and scope

  • Tracker or issue: fix(wallet-toolbox): an interrupted SQLite migration leaves a store that can never migrate again #538
  • Program gate(s) advanced: none claimed
  • Why this change is needed: on SQLite, StorageKnex.migrate() disables per-file transactions, so DDL autocommits while knex_migrations is written 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 objects the journal never recorded, and every later migrate.latest() re-runs that file from its first statement and fails on table ... already exists, permanently, with no in-package recovery path. For a wallet with a local SQLite store, a force-quit during first-run bring-up is enough.
  • Explicitly out of scope: dropAllData()'s own disableTransactions: isSQLite (a migrate-down path used by test tooling) is deliberately untouched. No migration file is edited.
  • Exact head SHA reviewed: 88130dc (branched from 82ab4d3c)

Impact

  • No public package source or manifest changed
  • Public package source or manifest changed; affected packages are listed below
  • Infrastructure source, dependency, image, or deployment configuration changed
  • Public API, exports, types, runtime targets, or browser/mobile behavior changed
  • Security-sensitive boundary changed
  • Documentation or examples changed

Affected: @bsv/wallet-toolbox — one file, src/storage/StorageKnex.ts, +17 −5 (one behavioural value; the rest is comment). No signature, export, or type changes. MySQL behaviour is unchanged by construction: isSQLite is already false there, so disableTransactions was already false. Only the SQLite branch changes.

Verification

  • Local commands and results: jest --testPathIgnorePatterns='man.test.ts|live.test.ts|bench.test.ts|client/test|mobile/test'216/216 suites, 2023 passed, 1 skipped, exit 0. The changed line is genuinely covered: 19 test files call .migrate() against SQLite, including the shared test/utils/TestUtilsWalletStorage.ts harness.
  • Hosted CI: not yet run on this head — draft pending.
  • Conformance evidence: none applicable. Worth flagging for maintainers: conformance/vectors/sync/brc40-user-state.json is 24 vectors, 0 required/24 intended, and conformance/runner/ts/dispatchers/sync.ts imports no wallet-toolbox code (it reimplements the merge rule locally), so BRC-40 conformance would not detect a regression here either way.
  • Coverage delta: none — no test added or removed.
  • Lint/typecheck delta: none expected; build is clean.
  • Browser/mobile/packed-consumer evidence: not applicable — SQLite/knex is a Node storage path.
  • Performance or bundle-size delta: none; one config value.
  • I self-reviewed the complete diff for correctness, security, compatibility, public API, artifacts, dependencies, docs, and operations
  • All applicable checks are terminal and successful on the exact head

Behavioural evidence

Reproduced and fixed against a built artifact, driving the real StorageKnex.migrate() (interrupting one migration after its first statement, then migrating again as the app's next launch would):

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

On PRAGMA foreign_keys — the reason the previous comment gave

The replaced comment cites knex#4155. That constraint is real, but it is already satisfied without disabling transactions:

  • migrate() issues PRAGMA foreign_keys = OFF outside migrate.latest(), and SQLite's single-connection pool means the migration transaction runs on that connection. Measured by reading PRAGMA foreign_keys from inside a migration under better-sqlite3: 0 with transactions both enabled and disabled.
  • knex's own SQLite alter-table rebuild already expects this: sqlite3/schema/ddl.js alter() uses enforceForeignCheck = this.client.transacting ? null : false — deliberately leaving an ambient pragma alone when already transacting.

Because up() migrations carry 12 .alter() calls across 8 alterTable blocks — each a full table rebuild on SQLite — I tested that path on a populated table rather than the fresh database the suite uses, under both settings:

disableTransactions=true  (current): rows 4->4  orphanKept=true  fkPreserved=true  widened=true
disableTransactions=false (this PR): rows 4->4  orphanKept=true  fkPreserved=true  widened=true

The orphan is an FK-violating row: it survives only if foreign keys are genuinely off during the rebuild. Identical under both.

Why not idempotence guards

Extending the SYNC_TRANSFER_MIGRATION hasTable pattern was the first choice and was rejected on counting. Counting up() regions only on this head: 49 DDL sites — 25 createTable, 4 column-adding alterTable, 13 index-adding alterTable, and 8 alterTable blocks carrying 12 .alter() calls. knex exposes no portable hasIndex for the index group, and an .alter() rebuild cannot be guarded by presence at all, since the column exists before and after. Guards are therefore a partial fix that also never closes the journal-write window.

Security and dependencies

  • No dependency or lockfile change
  • CodeQL/negative tests cover any changed trust boundary — not applicable; no trust boundary changed
  • The exact-head CodeQL analysis has no new alert — pending CI
  • The exact-head repository quality gate reports zero new Sonar findings — pending CI
  • No new override, advisory dismissal, quality suppression, or skipped test

Release and operations

  • No npm publication was performed from a workstation or from this PR
  • Required npm patch bumps are included or intentionally deferred by the controlling program — deferred to maintainers
  • Image/SBOM/provenance/deployment/rollback impact is documented — none; source-only change, revert is the rollback
  • Documentation, changelog, migration, and operational guidance are current — happy to add a changelog entry in the form you prefer

Completion evidence

Disclosure

The root manifest declares engines.node >= 24.11. I do not have that toolchain available and built/tested under Node v22.22.0 with --config.engine-strict=false (the wallet-toolbox package's own floor is >=22). Everything above was measured under that configuration. Nothing should be re-qualified from my run — please treat hosted CI on the exact head as the evidence of record. I am happy to re-run anything locally in a form you specify, or to rework this as guards instead if you prefer that direction.

…nsaction

With transactions disabled for SQLite, each DDL statement autocommits 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 objects the journal never recorded, and every later
migrate.latest() re-runs that file from its first statement and fails on
"table ... already exists" or "duplicate column name", permanently, with no
in-package recovery path. For a wallet with a local SQLite store, 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 its journal insert atomic with its
DDL. This also covers the alterTable and index-adding migrations, which
statement-level idempotence guards cannot: knex exposes no portable hasIndex,
and guards never close the journal-write window.

PRAGMA foreign_keys is still issued outside migrate.latest(). SQLite's
single-connection pool means the migration transaction inherits it, and knex's
own alter-table rebuild deliberately leaves an ambient pragma alone while
transacting (sqlite3/schema/ddl.js: enforceForeignCheck = transacting ? null :
false), so the knex#4155 constraint the previous comment describes still holds.

MySQL behaviour is unchanged: isSQLite was already false there, so
disableTransactions was already false. dropAllData()'s own setting is
deliberately untouched.

Closes bsv-blockchain#538
@sonarqubecloud

Copy link
Copy Markdown

@E-Jacko

E-Jacko commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Correction to my own numbers, made in the body above.

The migration counts I first posted (21 createTable / 40 alterTable) were measured against a downstream 2.10.2 build, not this repository. On main the figures are 25 createTable / 45 alterTable, with the same 2 hasTable guards. The guard alternative is correspondingly larger than I stated — 49 DDL sites across up() regions, and it includes 8 alterTable blocks carrying 12 .alter() calls, which are not guardable by presence at all since the column exists before and after.

This makes the guard option worse than I described, not better, so it does not change the recommendation — but the published numbers were wrong and are now corrected. The defect, the reproduction and the PRAGMA foreign_keys measurements are unaffected; those were taken against the real StorageKnex.migrate() on both builds.

Counting rule for anything quoted above: literal occurrence counts, and where stated, restricted to up() regions only.

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