Files
minibits_wallet/__tests__/dbMigrationRegistry.test.ts
T
minibits-cashandClaude Opus 4.8 2f7a2763f2 Reference mints by stable id, not by url
Completes the mint-identity work. A mint url is a network locator and mints
move, but the url had become the de facto foreign key for nearly every
persisted row, so a url edit had to fan out across the schema and mostly did
not. Each table now references the mint by an identity that a move cannot
disturb, and setMintUrl shrinks to the two things that genuinely hold a url:
Mint.mintUrl and the proofs.mintUrl cache.

Which identity, per table

mintId (Mint.id) where the row needs the mint itself:

- onchain_mint_quotes. The critical one: a quote's address stays creditable for
  as long as the mint exists (rows are never deleted), so the reference has to
  outlive a move. It followed row.mintUrl, and the watcher swallows errors by
  design — a renamed mint stranded deposits permanently and silently.
- reservations. A url edit racing an open send did not merely misfile the
  proofs; commitReservation resolved the mint by url, so it threw "Mint not
  found" and aborted the commit of an operation the mint had already performed.
- transactions. `mint` was two things at once, switched by status: a historical
  record of where a finished payment happened, AND a live pointer dialled for an
  open one. That conflation is why a rename had to rewrite in-flight rows —
  rewriting the very column that records the past. mintId takes the identity
  job, so `mint` is frozen as history and the status-scoped rewrite is retired.

No reference at all where the row already has a parent:

- inflight_requests, melt_recovery are CHILD rows of a transaction (their
  primary key IS transactionId), so the parent owns "which mint". Their mintUrl
  and keysetId copies had ZERO readers — the keyset that is used comes from
  inside meltPreview. Both dropped; the one mint-scoped query joins through
  transactions.mintId. Nothing left to go stale.

Mint.id gets referential authority only, never identity authority: findById
answers "which mint is this row about?" and must never answer "are these the
same mint?" — it is random and unrelated to the keys, so that question stays
with the keysets. The backfills are IS NULL-guarded so a resolved row can never
be re-pointed at whichever mint now answers an old url.

Backfills run from JS (v38 seed), not SQL: mints live in the MST/MMKV snapshot,
so nothing in SQL can map url -> id. Matching on url is trustworthy at exactly
that moment and no other — until now a url could not change without these rows
being rewritten to match. The join is spent once, at rest, instead of on every
rename.

Migration-system fixes found along the way

- _dbVersion is now DERIVED from the migration list. It was a hand-maintained
  literal, and it was already wrong: it said 33 while migration 34 existed, so
  34 would never have run. The failure is silent and asymmetric — fresh installs
  build from schema.ts and are fine, while upgrading devices land on a schema
  the code does not have. dbMigrationRegistry.test.ts pins this and the ordering
  invariants.
- Migrations 26/28/29/31 built their tables from the LIVE schema constants. A
  device replaying them would get today's shape, and the later ALTER adding the
  column would fail with "duplicate column name" — breaking upgrades from
  exactly the versions those migrations serve. Historical shapes are frozen
  locally now, and the schema.ts header no longer recommends the sharing.
- rootStoreModelVersion was left at 37 while the seed guarded on < 38, so the
  backfill would have re-run on every launch forever.

Tests: 421 pass. Six hand-mirrored suites had drifted from the schema they
claim to mirror; transactionsMintUrl.test.ts was worse — still passing while
testing a function this commit deletes, so it is removed. The mirroring pattern
is worth revisiting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:41:00 +02:00

48 lines
1.9 KiB
TypeScript

/**
* Invariants of the migration registry itself (services/db/migrations.ts).
*
* These guard the wiring rather than any one migration's SQL. The failure they
* exist for is silent and asymmetric: a migration whose version is not below
* `_dbVersion` never runs (the runner applies only `currentVersion <
* migration.version`, then stores `_dbVersion`), so upgrading devices end up on a
* schema the code does not have and fail later with a missing column — while a
* FRESH install is perfectly fine, because it builds from schema.ts and skips
* migrations entirely. That asymmetry is what makes it easy to ship.
*
* migrations.ts imports only a type from ./connection (elided at runtime), so it
* loads without the native op-sqlite module.
*
* @jest-environment node
*/
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
import {_dbVersion, MIGRATIONS} from '../src/services/db/migrations'
describe('migration registry', () => {
test('_dbVersion equals the newest migration version', () => {
const newest = Math.max(...MIGRATIONS.map(m => m.version))
expect(_dbVersion).toBe(newest)
})
test('every migration version is unique', () => {
const versions = MIGRATIONS.map(m => m.version)
expect(new Set(versions).size).toBe(versions.length)
})
test('migrations are in ascending order', () => {
// The runner concatenates matching migrations in array order and runs them as
// one batch, so the array order IS the execution order — a later-versioned
// entry placed earlier would apply out of sequence.
const versions = MIGRATIONS.map(m => m.version)
expect(versions).toEqual([...versions].sort((a, b) => a - b))
})
test('no migration is empty', () => {
for (const m of MIGRATIONS) {
expect(m.queries.length).toBeGreaterThan(0)
}
})
})