Files
minibits_wallet/__tests__/sqliteMigration33.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

287 lines
10 KiB
TypeScript

/**
* Repeatable migration tests for SQLite migration 33.
*
* Migration 33 adds `mintId` to `onchain_mint_quotes` and `reservations`, so those
* rows reference the owning mint by its stable id instead of by url.
*
* WHY: a mint url is a network LOCATOR and mints move. An onchain quote's address
* stays creditable for as long as the mint exists (rows are never deleted), so the
* reference has to outlive the move; following a stale url just polls a dead host
* forever, and silently, because the watcher swallows errors by design.
*
* The column is added EMPTY and backfilled from JS (the v38 seed), because mints
* live in the MST/MMKV snapshot rather than SQLite — no SQL statement can map
* url -> id. These tests cover the migration shape, the backfill semantics, and the
* replay hazard that made v26/v31 freeze their historical column lists.
*
* Uses Node.js built-in node:sqlite (requires Node 22.5+).
* @jest-environment node
*/
import {DatabaseSync} from 'node:sqlite'
// ── Historical shapes, as v26/v31 create them (frozen in migrations.ts) ───────
const CREATE_ONCHAIN_V31 = `CREATE TABLE onchain_mint_quotes (
quote TEXT PRIMARY KEY NOT NULL,
mintUrl TEXT NOT NULL,
unit TEXT NOT NULL,
address TEXT NOT NULL,
counterIndex INTEGER NOT NULL,
pubkey TEXT NOT NULL,
amountRequested INTEGER,
amountPaid INTEGER NOT NULL DEFAULT 0,
amountIssued INTEGER NOT NULL DEFAULT 0,
expiry INTEGER,
watchUntil TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT
)`
const CREATE_RESERVATIONS_V26 = `CREATE TABLE reservations (
id TEXT PRIMARY KEY NOT NULL,
transactionId INTEGER NOT NULL,
mintUrl TEXT NOT NULL,
unit TEXT NOT NULL,
operationType TEXT NOT NULL,
lockedProofs TEXT NOT NULL,
createdAt TEXT NOT NULL
)`
// ── SQL copied verbatim from migrations.ts migration 33 ──────────────────────
const MIGRATION_33 = [
`ALTER TABLE onchain_mint_quotes ADD COLUMN mintId TEXT`,
`ALTER TABLE reservations ADD COLUMN mintId TEXT`,
]
// ── Backfill, mirroring onchainQuotesRepo / reservationsRepo ─────────────────
function backfillOnchainMintQuoteMintIds(
db: DatabaseSync,
mints: Array<{id: string; mintUrl: string}>,
): number {
let updated = 0
for (const mint of mints) {
const {changes} = db
.prepare(`UPDATE onchain_mint_quotes SET mintId = ? WHERE mintUrl = ? AND mintId IS NULL`)
.run(mint.id, mint.mintUrl)
updated += Number(changes)
}
return updated
}
function backfillReservationMintIds(
db: DatabaseSync,
mints: Array<{id: string; mintUrl: string}>,
): number {
let updated = 0
for (const mint of mints) {
const {changes} = db
.prepare(`UPDATE reservations SET mintId = ? WHERE mintUrl = ? AND mintId IS NULL`)
.run(mint.id, mint.mintUrl)
updated += Number(changes)
}
return updated
}
// ── Helpers ──────────────────────────────────────────────────────────────────
const MINT_A = {id: 'aaaa1111', mintUrl: 'https://a.mint.test'}
const MINT_B = {id: 'bbbb2222', mintUrl: 'https://b.mint.test'}
function runMigration33(db: DatabaseSync) {
db.exec('BEGIN')
try {
for (const sql of MIGRATION_33) db.exec(sql)
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}
function insertQuote(db: DatabaseSync, quote: string, mintUrl: string) {
db.prepare(
`INSERT INTO onchain_mint_quotes
(quote, mintUrl, unit, address, counterIndex, pubkey, amountPaid, amountIssued, watchUntil, createdAt)
VALUES (?, ?, 'sat', 'bc1qaddr', 7, 'pub', 0, 0, '2026-12-01', '2026-01-01')`,
).run(quote, mintUrl)
}
function insertReservation(db: DatabaseSync, id: string, mintUrl: string) {
db.prepare(
`INSERT INTO reservations (id, transactionId, mintUrl, unit, operationType, lockedProofs, createdAt)
VALUES (?, 1, ?, 'sat', 'send', '[]', '2026-01-01')`,
).run(id, mintUrl)
}
function quoteMintId(db: DatabaseSync, quote: string): string | null {
const row = db.prepare('SELECT mintId FROM onchain_mint_quotes WHERE quote = ?').get(quote) as
| {mintId: string | null}
| undefined
return row?.mintId ?? null
}
function reservationMintId(db: DatabaseSync, id: string): string | null {
const row = db.prepare('SELECT mintId FROM reservations WHERE id = ?').get(id) as
| {mintId: string | null}
| undefined
return row?.mintId ?? null
}
function columns(db: DatabaseSync, table: string): string[] {
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as unknown as Array<{name: string}>
return rows.map(r => r.name)
}
function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:')
db.exec(CREATE_ONCHAIN_V31)
db.exec(CREATE_RESERVATIONS_V26)
return db
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('SQLite migration 33 — reference the mint by id', () => {
describe('shape', () => {
test('adds mintId to both tables', () => {
const db = freshDb()
expect(columns(db, 'onchain_mint_quotes')).not.toContain('mintId')
expect(columns(db, 'reservations')).not.toContain('mintId')
runMigration33(db)
expect(columns(db, 'onchain_mint_quotes')).toContain('mintId')
expect(columns(db, 'reservations')).toContain('mintId')
db.close()
})
test('keeps mintUrl — it stays as the historical record', () => {
const db = freshDb()
runMigration33(db)
expect(columns(db, 'onchain_mint_quotes')).toContain('mintUrl')
expect(columns(db, 'reservations')).toContain('mintUrl')
db.close()
})
test('preserves existing rows, with mintId NULL until backfilled', () => {
const db = freshDb()
insertQuote(db, 'q1', MINT_A.mintUrl)
runMigration33(db)
const row = db.prepare('SELECT * FROM onchain_mint_quotes WHERE quote = ?').get('q1') as any
expect(row.mintId).toBeNull()
// The load-bearing column: without counterIndex the NUT-20 key cannot be
// re-derived and a deposit is unmintable.
expect(row.counterIndex).toBe(7)
expect(row.address).toBe('bc1qaddr')
expect(row.mintUrl).toBe(MINT_A.mintUrl)
db.close()
})
// The reason v26/v31 freeze their column lists instead of reading schema.ts.
// If a replayed old migration created today's shape, this ALTER would throw
// "duplicate column name" and the upgrade would fail outright.
test('re-running the ALTER on an already-migrated table throws', () => {
const db = freshDb()
runMigration33(db)
expect(() => db.exec(`ALTER TABLE onchain_mint_quotes ADD COLUMN mintId TEXT`)).toThrow()
db.close()
})
})
describe('backfill', () => {
test('resolves each row to its mint id', () => {
const db = freshDb()
insertQuote(db, 'q1', MINT_A.mintUrl)
insertQuote(db, 'q2', MINT_B.mintUrl)
insertReservation(db, 'r1', MINT_A.mintUrl)
runMigration33(db)
expect(backfillOnchainMintQuoteMintIds(db, [MINT_A, MINT_B])).toBe(2)
expect(backfillReservationMintIds(db, [MINT_A, MINT_B])).toBe(1)
expect(quoteMintId(db, 'q1')).toBe(MINT_A.id)
expect(quoteMintId(db, 'q2')).toBe(MINT_B.id)
expect(reservationMintId(db, 'r1')).toBe(MINT_A.id)
db.close()
})
test('is idempotent — a second run updates nothing', () => {
const db = freshDb()
insertQuote(db, 'q1', MINT_A.mintUrl)
runMigration33(db)
expect(backfillOnchainMintQuoteMintIds(db, [MINT_A])).toBe(1)
expect(backfillOnchainMintQuoteMintIds(db, [MINT_A])).toBe(0)
expect(quoteMintId(db, 'q1')).toBe(MINT_A.id)
db.close()
})
// The IS NULL guard: once a row is resolved, a later url match must never
// re-point it. After a rename the row's mintUrl is stale by design, so a
// re-run must not "correct" it toward whichever mint now answers that url.
test('never overwrites an id already resolved', () => {
const db = freshDb()
insertQuote(db, 'q1', MINT_A.mintUrl)
runMigration33(db)
backfillOnchainMintQuoteMintIds(db, [MINT_A])
// A different mint has since taken over that url.
const impostor = {id: 'cccc3333', mintUrl: MINT_A.mintUrl}
expect(backfillOnchainMintQuoteMintIds(db, [impostor])).toBe(0)
expect(quoteMintId(db, 'q1')).toBe(MINT_A.id)
db.close()
})
test('leaves a row whose mint is gone from the wallet NULL', () => {
const db = freshDb()
insertQuote(db, 'orphan', 'https://removed.mint.test')
runMigration33(db)
expect(backfillOnchainMintQuoteMintIds(db, [MINT_A, MINT_B])).toBe(0)
// Null means "no mint to talk to" — the quote is dead either way, and the
// resolver throws rather than guessing from the stale url.
expect(quoteMintId(db, 'orphan')).toBeNull()
db.close()
})
test('is a no-op with no mints', () => {
const db = freshDb()
insertQuote(db, 'q1', MINT_A.mintUrl)
runMigration33(db)
expect(backfillOnchainMintQuoteMintIds(db, [])).toBe(0)
expect(quoteMintId(db, 'q1')).toBeNull()
db.close()
})
})
describe('the point of the whole change', () => {
test('a quote still resolves after its mint moves url', () => {
const db = freshDb()
insertQuote(db, 'q1', MINT_A.mintUrl)
runMigration33(db)
backfillOnchainMintQuoteMintIds(db, [MINT_A])
// The mint moves. Nothing rewrites the quote row — that is the design.
const movedMint = {id: MINT_A.id, mintUrl: 'https://moved.mint.test'}
// Resolution is by id, so it still finds the mint and gets its LIVE url.
expect(quoteMintId(db, 'q1')).toBe(movedMint.id)
// Whereas the old url-keyed lookup now finds nothing — this is precisely the
// query that stranded deposits permanently.
const byOldUrl = db
.prepare('SELECT quote FROM onchain_mint_quotes WHERE mintUrl = ?')
.all(movedMint.mintUrl)
expect(byOldUrl).toHaveLength(0)
const byId = db.prepare('SELECT quote FROM onchain_mint_quotes WHERE mintId = ?').all(movedMint.id)
expect(byId).toHaveLength(1)
db.close()
})
})
})