mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-08-10 08:37:07 +00:00
Relocate per-transaction in-flight request data (params for NUT-19
idempotent retry) from the MST MintProofsCounter to a dedicated SQLite
table, so retries work with no MST loaded — completing the off-MST set
needed for background NWC.
- schema/migration v29: inflight_requests (txId PK, mintUrl, keysetId,
request JSON); added to cleanAll.
- inFlightRepo: add (INSERT OR REPLACE = set semantics) / get /
getInFlightRequestsByMint / remove / seed (ON CONFLICT DO NOTHING).
- WalletStore: write/remove via Database (receive/send/mint paths).
- inFlightOperations: enumerate via a flat Database.getInFlightRequestsByMint
query instead of the mint.proofsCountersWithInFlightRequests nested loop;
removeInFlightRequest via Database; queue guard uses the DB count.
- Mint model: remove inFlightRequests map, InFlightRequestModel, all the
in-flight actions/views (counter + mint level). The InFlightRequest TYPE
is kept (WalletStore option signatures). MintProofsCounter is now just
{keyset, unit, counter}. migrateSnapshot strips inFlightRequests AND
meltCounterValues from old snapshots.
- one-time seed (rootStoreModelVersion 34->35): _runMigrations reads the
raw pre-upgrade snapshot for any in-flight requests; idempotent.
- tests: __tests__/inFlightRequests.test.ts.
Full suite green (15 suites / 163 tests). With M1+M2, the MintProofsCounter
sub-model now carries only the counter (itself SQLite-authoritative) — a
candidate to collapse later.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
143 lines
5.3 KiB
TypeScript
143 lines
5.3 KiB
TypeScript
/**
|
|
* In-flight request tests (inFlightRequests → SQLite migration).
|
|
*
|
|
* Per-transaction request params stored so an op whose mint response was lost
|
|
* can be retried against the mint's idempotent endpoint. add() overwrites
|
|
* (set semantics), the per-mint query drives the recovery sweep, the row is
|
|
* deleted on success/terminal failure, and the upgrade seed is idempotent.
|
|
*
|
|
* Mirrors the production SQL against node:sqlite (the native driver needs a
|
|
* device), like meltRecovery.test.ts.
|
|
*
|
|
* @jest-environment node
|
|
*/
|
|
import {DatabaseSync} from 'node:sqlite'
|
|
|
|
const NOW = '2026-06-05T00:00:00.000Z'
|
|
|
|
const CREATE_INFLIGHT = `CREATE TABLE inflight_requests (
|
|
transactionId INTEGER PRIMARY KEY NOT NULL,
|
|
mintUrl TEXT,
|
|
keysetId TEXT,
|
|
request TEXT NOT NULL,
|
|
createdAt TEXT
|
|
)`
|
|
|
|
const MINT = 'https://mint.test'
|
|
|
|
// ── Mirrored repo primitives (exact production SQL) ─────────────────────────
|
|
|
|
function addInFlightRequest(
|
|
db: DatabaseSync,
|
|
transactionId: number,
|
|
mintUrl: string | null,
|
|
keysetId: string | null,
|
|
request: object,
|
|
) {
|
|
db.prepare(
|
|
`INSERT OR REPLACE INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
).run(transactionId, mintUrl, keysetId, JSON.stringify(request), NOW)
|
|
}
|
|
|
|
function getInFlightRequest(db: DatabaseSync, transactionId: number) {
|
|
const row = db
|
|
.prepare(`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE transactionId = ?`)
|
|
.get(transactionId) as {transactionId: number; mintUrl: string | null; keysetId: string | null; request: string; createdAt: string | null} | undefined
|
|
if (!row) return undefined
|
|
return {...row, request: JSON.parse(row.request)}
|
|
}
|
|
|
|
function getInFlightRequestsByMint(db: DatabaseSync, mintUrl: string) {
|
|
const rows = db
|
|
.prepare(`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE mintUrl = ?`)
|
|
.all(mintUrl) as Array<{transactionId: number; mintUrl: string | null; keysetId: string | null; request: string; createdAt: string | null}>
|
|
return rows.map(r => ({...r, request: JSON.parse(r.request)}))
|
|
}
|
|
|
|
function removeInFlightRequest(db: DatabaseSync, transactionId: number) {
|
|
db.prepare(`DELETE FROM inflight_requests WHERE transactionId = ?`).run(transactionId)
|
|
}
|
|
|
|
function seedInFlightRequest(
|
|
db: DatabaseSync,
|
|
transactionId: number,
|
|
mintUrl: string | null,
|
|
keysetId: string | null,
|
|
request: object,
|
|
) {
|
|
db.prepare(
|
|
`INSERT INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(transactionId) DO NOTHING`,
|
|
).run(transactionId, mintUrl, keysetId, JSON.stringify(request), NOW)
|
|
}
|
|
|
|
function freshDb(): DatabaseSync {
|
|
const db = new DatabaseSync(':memory:')
|
|
db.exec(CREATE_INFLIGHT)
|
|
return db
|
|
}
|
|
|
|
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
|
|
describe('In-flight requests (inflight_requests)', () => {
|
|
test('stores and reads back a request (JSON round-trip)', () => {
|
|
const db = freshDb()
|
|
const request = {token: 'cashuA...', options: {keysetId: 'k1'}}
|
|
|
|
addInFlightRequest(db, 101, MINT, 'k1', request)
|
|
const rec = getInFlightRequest(db, 101)!
|
|
|
|
expect(rec.transactionId).toBe(101)
|
|
expect(rec.mintUrl).toBe(MINT)
|
|
expect(rec.keysetId).toBe('k1')
|
|
expect(rec.request).toEqual(request)
|
|
db.close()
|
|
})
|
|
|
|
test('returns undefined when no entry exists', () => {
|
|
const db = freshDb()
|
|
expect(getInFlightRequest(db, 999)).toBeUndefined()
|
|
db.close()
|
|
})
|
|
|
|
test('add OVERWRITES an existing entry (set semantics)', () => {
|
|
const db = freshDb()
|
|
addInFlightRequest(db, 101, MINT, 'k1', {v: 'first'})
|
|
addInFlightRequest(db, 101, MINT, 'k1', {v: 'second'})
|
|
|
|
expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'second'})
|
|
db.close()
|
|
})
|
|
|
|
test('getInFlightRequestsByMint returns all rows for a mint', () => {
|
|
const db = freshDb()
|
|
addInFlightRequest(db, 101, MINT, 'k1', {v: 1})
|
|
addInFlightRequest(db, 102, MINT, 'k1', {v: 2})
|
|
addInFlightRequest(db, 103, 'https://other.test', 'k9', {v: 3})
|
|
|
|
const forMint = getInFlightRequestsByMint(db, MINT)
|
|
expect(forMint.map(r => r.transactionId).sort()).toEqual([101, 102])
|
|
expect(getInFlightRequestsByMint(db, 'https://other.test')).toHaveLength(1)
|
|
db.close()
|
|
})
|
|
|
|
test('remove deletes the entry', () => {
|
|
const db = freshDb()
|
|
addInFlightRequest(db, 101, MINT, 'k1', {v: 1})
|
|
removeInFlightRequest(db, 101)
|
|
expect(getInFlightRequest(db, 101)).toBeUndefined()
|
|
expect(getInFlightRequestsByMint(db, MINT)).toHaveLength(0)
|
|
db.close()
|
|
})
|
|
|
|
test('seed is idempotent — does not overwrite an existing entry', () => {
|
|
const db = freshDb()
|
|
addInFlightRequest(db, 101, MINT, 'k1', {v: 'live'})
|
|
seedInFlightRequest(db, 101, MINT, 'k1', {v: 'snapshot'})
|
|
expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'live'})
|
|
db.close()
|
|
})
|
|
})
|