mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-08-09 16:24:55 +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>
274 lines
9.8 KiB
TypeScript
274 lines
9.8 KiB
TypeScript
/**
|
|
* Repeatable migration tests for SQLite migration 34.
|
|
*
|
|
* Migration 34 finishes the mint-identity work:
|
|
*
|
|
* 1. `transactions.mintId` — splitting the two jobs `transactions.mint` was doing
|
|
* at once, switched by status: a historical record of where a finished payment
|
|
* happened, AND a live pointer the wallet dialled for an open one. That is why
|
|
* a mint-url edit used to rewrite in-flight rows — rewriting the very column
|
|
* that records the past. With mintId carrying identity, `mint` is frozen.
|
|
*
|
|
* 2. inflight_requests / melt_recovery rebuilt WITHOUT mintUrl and keysetId. Both
|
|
* are CHILD rows of a transaction (their primary key IS transactionId), so the
|
|
* parent already owns "which mint"; their copies had no readers at all. The one
|
|
* mint-scoped query joins through the parent instead.
|
|
*
|
|
* Uses Node.js built-in node:sqlite (requires Node 22.5+).
|
|
* @jest-environment node
|
|
*/
|
|
import {DatabaseSync} from 'node:sqlite'
|
|
|
|
// ── Pre-34 shapes (as v28/v29 create them; frozen in migrations.ts) ──────────
|
|
|
|
const CREATE_TRANSACTIONS_PRE34 = `CREATE TABLE transactions (
|
|
id INTEGER PRIMARY KEY NOT NULL,
|
|
type TEXT,
|
|
amount INTEGER,
|
|
unit TEXT,
|
|
data TEXT,
|
|
mint TEXT,
|
|
status TEXT,
|
|
createdAt TEXT
|
|
)`
|
|
|
|
const CREATE_INFLIGHT_V29 = `CREATE TABLE inflight_requests (
|
|
transactionId INTEGER PRIMARY KEY NOT NULL,
|
|
mintUrl TEXT,
|
|
keysetId TEXT,
|
|
request TEXT NOT NULL,
|
|
createdAt TEXT
|
|
)`
|
|
|
|
const CREATE_MELT_RECOVERY_V28 = `CREATE TABLE melt_recovery (
|
|
transactionId INTEGER PRIMARY KEY NOT NULL,
|
|
mintUrl TEXT,
|
|
keysetId TEXT,
|
|
meltPreview TEXT NOT NULL,
|
|
createdAt TEXT
|
|
)`
|
|
|
|
// ── SQL copied verbatim from migrations.ts migration 34 ─────────────────────
|
|
|
|
const MIGRATION_34 = [
|
|
`ALTER TABLE transactions ADD COLUMN mintId TEXT`,
|
|
|
|
`CREATE TABLE inflight_requests_v34 (
|
|
transactionId INTEGER PRIMARY KEY NOT NULL,
|
|
request TEXT NOT NULL,
|
|
createdAt TEXT
|
|
)`,
|
|
`INSERT INTO inflight_requests_v34 (transactionId, request, createdAt)
|
|
SELECT transactionId, request, createdAt FROM inflight_requests`,
|
|
`DROP TABLE inflight_requests`,
|
|
`ALTER TABLE inflight_requests_v34 RENAME TO inflight_requests`,
|
|
|
|
`CREATE TABLE melt_recovery_v34 (
|
|
transactionId INTEGER PRIMARY KEY NOT NULL,
|
|
meltPreview TEXT NOT NULL,
|
|
createdAt TEXT
|
|
)`,
|
|
`INSERT INTO melt_recovery_v34 (transactionId, meltPreview, createdAt)
|
|
SELECT transactionId, meltPreview, createdAt FROM melt_recovery`,
|
|
`DROP TABLE melt_recovery`,
|
|
`ALTER TABLE melt_recovery_v34 RENAME TO melt_recovery`,
|
|
]
|
|
|
|
/** Mirrors transactionsRepo.backfillTransactionMintIds (the v38 seed). */
|
|
function backfillTransactionMintIds(
|
|
db: DatabaseSync,
|
|
mints: Array<{id: string; mintUrl: string}>,
|
|
): number {
|
|
let updated = 0
|
|
for (const mint of mints) {
|
|
const {changes} = db
|
|
.prepare(`UPDATE transactions SET mintId = ? WHERE mint = ? 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 runMigration34(db: DatabaseSync) {
|
|
db.exec('BEGIN')
|
|
try {
|
|
for (const sql of MIGRATION_34) db.exec(sql)
|
|
db.exec('COMMIT')
|
|
} catch (e) {
|
|
db.exec('ROLLBACK')
|
|
throw e
|
|
}
|
|
}
|
|
|
|
function insertTx(db: DatabaseSync, id: number, mint: string, status = 'COMPLETED') {
|
|
db.prepare(
|
|
`INSERT INTO transactions (id, type, amount, unit, data, mint, status, createdAt)
|
|
VALUES (?, 'TOPUP', 100, 'sat', '{}', ?, ?, '2026-01-01')`,
|
|
).run(id, mint, status)
|
|
}
|
|
|
|
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_TRANSACTIONS_PRE34)
|
|
db.exec(CREATE_INFLIGHT_V29)
|
|
db.exec(CREATE_MELT_RECOVERY_V28)
|
|
return db
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('SQLite migration 34 — transactions.mintId, and child tables normalized', () => {
|
|
describe('transactions.mintId', () => {
|
|
test('adds mintId while keeping mint', () => {
|
|
const db = freshDb()
|
|
runMigration34(db)
|
|
expect(columns(db, 'transactions')).toContain('mintId')
|
|
// `mint` stays: it is where the payment happened, and history needs it.
|
|
expect(columns(db, 'transactions')).toContain('mint')
|
|
db.close()
|
|
})
|
|
|
|
test('backfills mintId from the url, leaving mint untouched', () => {
|
|
const db = freshDb()
|
|
insertTx(db, 1, MINT_A.mintUrl)
|
|
insertTx(db, 2, MINT_B.mintUrl)
|
|
runMigration34(db)
|
|
|
|
expect(backfillTransactionMintIds(db, [MINT_A, MINT_B])).toBe(2)
|
|
|
|
const rows = db.prepare('SELECT id, mint, mintId FROM transactions ORDER BY id').all() as any[]
|
|
expect(rows[0]).toMatchObject({mint: MINT_A.mintUrl, mintId: MINT_A.id})
|
|
expect(rows[1]).toMatchObject({mint: MINT_B.mintUrl, mintId: MINT_B.id})
|
|
db.close()
|
|
})
|
|
|
|
test('leaves history of a removed mint with a null mintId', () => {
|
|
const db = freshDb()
|
|
insertTx(db, 1, 'https://removed.mint.test')
|
|
runMigration34(db)
|
|
|
|
expect(backfillTransactionMintIds(db, [MINT_A])).toBe(0)
|
|
const row = db.prepare('SELECT mint, mintId FROM transactions WHERE id = 1').get() as any
|
|
// Legitimate history: no mint to act on, but the url still records where it
|
|
// happened.
|
|
expect(row.mintId).toBeNull()
|
|
expect(row.mint).toBe('https://removed.mint.test')
|
|
db.close()
|
|
})
|
|
|
|
test('backfill is idempotent and never re-points a resolved row', () => {
|
|
const db = freshDb()
|
|
insertTx(db, 1, MINT_A.mintUrl)
|
|
runMigration34(db)
|
|
backfillTransactionMintIds(db, [MINT_A])
|
|
|
|
// A different mint has since taken over that url.
|
|
const impostor = {id: 'cccc3333', mintUrl: MINT_A.mintUrl}
|
|
expect(backfillTransactionMintIds(db, [impostor])).toBe(0)
|
|
|
|
const row = db.prepare('SELECT mintId FROM transactions WHERE id = 1').get() as any
|
|
expect(row.mintId).toBe(MINT_A.id)
|
|
db.close()
|
|
})
|
|
|
|
test('backfills in-flight and terminal rows alike', () => {
|
|
const db = freshDb()
|
|
insertTx(db, 1, MINT_A.mintUrl, 'PENDING')
|
|
insertTx(db, 2, MINT_A.mintUrl, 'COMPLETED')
|
|
runMigration34(db)
|
|
|
|
// Unlike the rewrite this replaces, the backfill is status-blind: mintId is
|
|
// identity, which a finished transaction has just as much as an open one.
|
|
expect(backfillTransactionMintIds(db, [MINT_A])).toBe(2)
|
|
db.close()
|
|
})
|
|
})
|
|
|
|
describe('child tables lose their duplicated mint reference', () => {
|
|
test('inflight_requests keeps only transactionId, request, createdAt', () => {
|
|
const db = freshDb()
|
|
runMigration34(db)
|
|
expect(columns(db, 'inflight_requests').sort()).toEqual(['createdAt', 'request', 'transactionId'])
|
|
db.close()
|
|
})
|
|
|
|
test('melt_recovery keeps only transactionId, meltPreview, createdAt', () => {
|
|
const db = freshDb()
|
|
runMigration34(db)
|
|
expect(columns(db, 'melt_recovery').sort()).toEqual(['createdAt', 'meltPreview', 'transactionId'])
|
|
db.close()
|
|
})
|
|
|
|
test('carries the surviving data across the rebuild', () => {
|
|
const db = freshDb()
|
|
db.prepare(
|
|
`INSERT INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
|
|
VALUES (1, 'https://a.mint.test', 'k1', '{"v":1}', '2026-01-01')`,
|
|
).run()
|
|
db.prepare(
|
|
`INSERT INTO melt_recovery (transactionId, mintUrl, keysetId, meltPreview, createdAt)
|
|
VALUES (2, 'https://a.mint.test', 'k1', '{"keysetId":"k1"}', '2026-01-02')`,
|
|
).run()
|
|
|
|
runMigration34(db)
|
|
|
|
const ifr = db.prepare('SELECT * FROM inflight_requests WHERE transactionId = 1').get() as any
|
|
expect(ifr.request).toBe('{"v":1}')
|
|
expect(ifr.createdAt).toBe('2026-01-01')
|
|
|
|
const melt = db.prepare('SELECT * FROM melt_recovery WHERE transactionId = 2').get() as any
|
|
// The keyset was never lost — it lives inside the preview, which is why the
|
|
// column duplicating it had no readers.
|
|
expect(JSON.parse(melt.meltPreview).keysetId).toBe('k1')
|
|
expect(melt.createdAt).toBe('2026-01-02')
|
|
db.close()
|
|
})
|
|
|
|
test('an empty child table rebuilds cleanly', () => {
|
|
const db = freshDb()
|
|
runMigration34(db)
|
|
expect(db.prepare('SELECT * FROM inflight_requests').all()).toEqual([])
|
|
expect(db.prepare('SELECT * FROM melt_recovery').all()).toEqual([])
|
|
db.close()
|
|
})
|
|
})
|
|
|
|
describe('the mint-scoped sweep, after the change', () => {
|
|
test('finds a mint\'s in-flight requests through the parent transaction', () => {
|
|
const db = freshDb()
|
|
insertTx(db, 1, MINT_A.mintUrl, 'PENDING')
|
|
insertTx(db, 2, MINT_B.mintUrl, 'PENDING')
|
|
db.prepare(
|
|
`INSERT INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
|
|
VALUES (1, 'https://a.mint.test', 'k1', '{"v":1}', '2026-01-01'),
|
|
(2, 'https://b.mint.test', 'k2', '{"v":2}', '2026-01-01')`,
|
|
).run()
|
|
|
|
runMigration34(db)
|
|
backfillTransactionMintIds(db, [MINT_A, MINT_B])
|
|
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT r.transactionId FROM inflight_requests r
|
|
JOIN transactions t ON t.id = r.transactionId
|
|
WHERE t.mintId = ?`,
|
|
)
|
|
.all(MINT_A.id) as any[]
|
|
|
|
expect(rows).toHaveLength(1)
|
|
expect(rows[0].transactionId).toBe(1)
|
|
db.close()
|
|
})
|
|
})
|
|
})
|