mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-08-10 00:27:00 +00:00
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>
287 lines
10 KiB
TypeScript
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()
|
|
})
|
|
})
|
|
})
|