Every cash-out now produces one report_dispense — on success as well as failure — and the machine does not stop sending it until spirekeeper acknowledges it. state.db gains a dispense_reports table (migration v13 → v14): the report is written INSIDE recordTransaction's SQLite transaction, alongside the transactions row, so a crash between the two cannot lose it. Rows carry attempts / last_attempt_at / last_error / acked_at. Three IPC calls (pending / ack / note-attempt) expose it to the renderer. The store builds the report when a cash-out reaches complete, dispenseFault or outOfCash: txid, payment hash, dispense_confirmed, error / error_code / raw_code / error_class, per-denomination requested vs dispensed vs rejected, the per-bay cassette record verbatim, and counts_uncertain. The success report is what lets the server capture (distribute) the settlement; the failure report is what puts a customer on the owed-cash worklist instead of leaving the only record on the ATM. Delivery is at-least-once: a flusher drains pending rows after each persist, on relay (re)connect, and every 60 s, acking only on an OK reply and backing off 30 s · 2^attempts (capped 1 h) otherwise. While spirekeeper has not registered the RPC every send fails the same way; the backoff keeps that quiet and the rows wait — this half ships first. The lightning service exposes reportDispense; the function pointer is set at all three lightning-init sites so the flusher works on every path.
1562 lines
60 KiB
TypeScript
1562 lines
60 KiB
TypeScript
/**
|
||
* SQLite State Store for ATM Cash Tracking
|
||
*
|
||
* Persists cassette inventory, cashbox state, and transaction history
|
||
* across reboots. Runs in the Electron main process using better-sqlite3
|
||
* (synchronous API, safe for single-process use).
|
||
*
|
||
* Database location: /var/lib/bitspire/state.db (production)
|
||
* ./state.db (development)
|
||
*/
|
||
|
||
import Database from 'better-sqlite3'
|
||
import type { DispenseReportBody } from '@bitSpire/lnbits'
|
||
import path from 'node:path'
|
||
import fs from 'node:fs'
|
||
|
||
let db: Database.Database | null = null
|
||
|
||
const SCHEMA_VERSION = '14'
|
||
|
||
function getDbPath(): string {
|
||
const prodDir = '/var/lib/bitspire'
|
||
if (fs.existsSync(prodDir)) {
|
||
return path.join(prodDir, 'state.db')
|
||
}
|
||
// Development fallback: next to the app
|
||
return path.join(process.cwd(), 'state.db')
|
||
}
|
||
|
||
/**
|
||
* Initialize the database, creating tables if they don't exist.
|
||
* Must be called once at app startup before any other functions.
|
||
*/
|
||
export function initDatabase(dbPath?: string): void {
|
||
const resolvedPath = dbPath ?? getDbPath()
|
||
|
||
// Ensure parent directory exists
|
||
const dir = path.dirname(resolvedPath)
|
||
if (!fs.existsSync(dir)) {
|
||
fs.mkdirSync(dir, { recursive: true })
|
||
}
|
||
|
||
db = new Database(resolvedPath)
|
||
|
||
// Enable WAL mode for better concurrent read performance
|
||
db.pragma('journal_mode = WAL')
|
||
|
||
// Create tables
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS meta (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS cassettes (
|
||
position INTEGER PRIMARY KEY,
|
||
denomination INTEGER NOT NULL,
|
||
count INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS cassette_ops (
|
||
id TEXT PRIMARY KEY,
|
||
position INTEGER NOT NULL,
|
||
op_type TEXT NOT NULL,
|
||
bills INTEGER,
|
||
count INTEGER,
|
||
denomination INTEGER,
|
||
op_at INTEGER NOT NULL,
|
||
applied_at INTEGER NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS cashbox (
|
||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||
total_bills INTEGER NOT NULL DEFAULT 0,
|
||
total_fiat_cents INTEGER NOT NULL DEFAULT 0,
|
||
last_emptied_at INTEGER
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS transactions (
|
||
txid TEXT PRIMARY KEY,
|
||
type TEXT NOT NULL CHECK (type IN ('cash_in', 'cash_out', 'manual_dispense')),
|
||
fiat_cents INTEGER NOT NULL,
|
||
sats INTEGER NOT NULL,
|
||
fee_sats INTEGER NOT NULL DEFAULT 0,
|
||
fee_fraction REAL NOT NULL DEFAULT 0,
|
||
exchange_rate REAL NOT NULL DEFAULT 0,
|
||
currency TEXT NOT NULL DEFAULT 'GTQ',
|
||
status TEXT NOT NULL DEFAULT 'complete',
|
||
error TEXT,
|
||
remediated_by TEXT,
|
||
created_at INTEGER NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS transaction_bills (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
txid TEXT NOT NULL REFERENCES transactions(txid),
|
||
denomination INTEGER NOT NULL,
|
||
count INTEGER NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS cassette_bills (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
txid TEXT NOT NULL REFERENCES transactions(txid),
|
||
name TEXT NOT NULL,
|
||
position INTEGER NOT NULL,
|
||
denomination INTEGER NOT NULL,
|
||
provisioned INTEGER NOT NULL DEFAULT 0,
|
||
dispensed INTEGER NOT NULL DEFAULT 0,
|
||
rejected INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS operator_commands (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
command TEXT NOT NULL,
|
||
status TEXT NOT NULL DEFAULT 'pending',
|
||
result TEXT,
|
||
created_at INTEGER NOT NULL,
|
||
completed_at INTEGER
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS fee_config (
|
||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||
cash_in_fee_fraction REAL NOT NULL,
|
||
cash_out_fee_fraction REAL NOT NULL,
|
||
schema_version INTEGER NOT NULL DEFAULT 1,
|
||
event_created_at INTEGER NOT NULL,
|
||
applied_at INTEGER NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS bunker_binding (
|
||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||
client_secret_hex TEXT NOT NULL,
|
||
spire_pubkey TEXT NOT NULL,
|
||
bunker_url TEXT NOT NULL,
|
||
seed_fingerprint TEXT NOT NULL,
|
||
paired_at INTEGER NOT NULL,
|
||
relays TEXT,
|
||
lnbits_server_pubkey TEXT
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS dispense_reports (
|
||
txid TEXT PRIMARY KEY REFERENCES transactions(txid),
|
||
payload TEXT NOT NULL,
|
||
created_at INTEGER NOT NULL,
|
||
attempts INTEGER NOT NULL DEFAULT 0,
|
||
last_attempt_at INTEGER,
|
||
last_error TEXT,
|
||
acked_at INTEGER
|
||
);
|
||
`)
|
||
|
||
// Seed meta + cashbox if first run, or run migrations
|
||
const existing = db.prepare('SELECT value FROM meta WHERE key = ?').get('schema_version') as
|
||
| { value: string }
|
||
| undefined
|
||
if (!existing) {
|
||
db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('schema_version', SCHEMA_VERSION)
|
||
} else if (existing.value === '1') {
|
||
// Migration v1 → v2: add fee columns to transactions
|
||
db.exec(`
|
||
ALTER TABLE transactions ADD COLUMN fee_sats INTEGER NOT NULL DEFAULT 0;
|
||
ALTER TABLE transactions ADD COLUMN fee_percent REAL NOT NULL DEFAULT 0;
|
||
`)
|
||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('2', 'schema_version')
|
||
console.log('[StateStore] Migrated schema v1 → v2 (added fee columns)')
|
||
existing.value = '2'
|
||
}
|
||
|
||
if (existing && existing.value === '2') {
|
||
// Migration v2 → v3: add exchange rate and currency
|
||
db.exec(`
|
||
ALTER TABLE transactions ADD COLUMN exchange_rate REAL NOT NULL DEFAULT 0;
|
||
ALTER TABLE transactions ADD COLUMN currency TEXT NOT NULL DEFAULT 'GTQ';
|
||
`)
|
||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('3', 'schema_version')
|
||
console.log('[StateStore] Migrated schema v2 → v3 (added exchange_rate, currency)')
|
||
existing.value = '3'
|
||
}
|
||
|
||
if (existing && existing.value === '3') {
|
||
// Migration v3 → v4: add status/error to transactions, cassette_bills table
|
||
db.exec(`
|
||
ALTER TABLE transactions ADD COLUMN status TEXT NOT NULL DEFAULT 'complete';
|
||
ALTER TABLE transactions ADD COLUMN error TEXT;
|
||
|
||
CREATE TABLE IF NOT EXISTS cassette_bills (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
txid TEXT NOT NULL REFERENCES transactions(txid),
|
||
name TEXT NOT NULL,
|
||
position INTEGER NOT NULL,
|
||
denomination INTEGER NOT NULL,
|
||
provisioned INTEGER NOT NULL DEFAULT 0,
|
||
dispensed INTEGER NOT NULL DEFAULT 0,
|
||
rejected INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
`)
|
||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('4', 'schema_version')
|
||
console.log('[StateStore] Migrated schema v3 → v4 (added status, error, cassette_bills)')
|
||
existing.value = '4'
|
||
}
|
||
|
||
if (existing && existing.value === '4') {
|
||
// Migration v4 → v5: add manual_dispense type and remediated_by column
|
||
// SQLite cannot ALTER CHECK constraints, so recreate the table.
|
||
// Disable FK constraints during migration to avoid errors from
|
||
// transaction_bills/cassette_bills referencing transactions(txid).
|
||
db.pragma('foreign_keys = OFF')
|
||
db.exec(`
|
||
DROP TABLE IF EXISTS transactions_new;
|
||
CREATE TABLE transactions_new (
|
||
txid TEXT PRIMARY KEY,
|
||
type TEXT NOT NULL CHECK (type IN ('cash_in', 'cash_out', 'manual_dispense')),
|
||
fiat_cents INTEGER NOT NULL,
|
||
sats INTEGER NOT NULL,
|
||
fee_sats INTEGER NOT NULL DEFAULT 0,
|
||
fee_percent REAL NOT NULL DEFAULT 0,
|
||
exchange_rate REAL NOT NULL DEFAULT 0,
|
||
currency TEXT NOT NULL DEFAULT 'GTQ',
|
||
status TEXT NOT NULL DEFAULT 'complete',
|
||
error TEXT,
|
||
remediated_by TEXT,
|
||
created_at INTEGER NOT NULL
|
||
);
|
||
INSERT INTO transactions_new SELECT txid, type, fiat_cents, sats, fee_sats, fee_percent,
|
||
exchange_rate, currency, status, error, NULL, created_at FROM transactions;
|
||
DROP TABLE transactions;
|
||
ALTER TABLE transactions_new RENAME TO transactions;
|
||
`)
|
||
db.pragma('foreign_keys = ON')
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS operator_commands (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
command TEXT NOT NULL,
|
||
status TEXT NOT NULL DEFAULT 'pending',
|
||
result TEXT,
|
||
created_at INTEGER NOT NULL,
|
||
completed_at INTEGER
|
||
);
|
||
`)
|
||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('5', 'schema_version')
|
||
console.log(
|
||
'[StateStore] Migrated schema v4 → v5 (added manual_dispense type, remediated_by, operator_commands)'
|
||
)
|
||
existing.value = '5'
|
||
}
|
||
|
||
if (existing && existing.value === '5') {
|
||
// Migration v5 → v6: add position column to cassettes for physical cartridge ordering
|
||
db.exec(`ALTER TABLE cassettes ADD COLUMN position INTEGER NOT NULL DEFAULT 0`)
|
||
// Backfill position from existing row order (by denomination)
|
||
const rows = db.prepare('SELECT denomination FROM cassettes ORDER BY denomination').all() as {
|
||
denomination: number
|
||
}[]
|
||
const update = db.prepare('UPDATE cassettes SET position = ? WHERE denomination = ?')
|
||
for (let i = 0; i < rows.length; i++) {
|
||
update.run(i + 1, rows[i]!.denomination)
|
||
}
|
||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('6', 'schema_version')
|
||
console.log('[StateStore] Migrated schema v5 → v6 (added cassette position)')
|
||
existing.value = '6'
|
||
}
|
||
|
||
if (existing && existing.value === '6') {
|
||
// Migration v6 → v7: rename fee_percent → fee_fraction. Same semantics
|
||
// (unit fraction in [0, 1]); the rename closes the 100× misinterpretation
|
||
// risk between satmachineadmin's consumer and bitspire's stamp.
|
||
// Coordinated with satmachineadmin commit d717a6e (v2-bitspire branch).
|
||
db.exec(`ALTER TABLE transactions RENAME COLUMN fee_percent TO fee_fraction`)
|
||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('7', 'schema_version')
|
||
console.log('[StateStore] Migrated schema v6 → v7 (renamed fee_percent → fee_fraction)')
|
||
existing.value = '7'
|
||
}
|
||
|
||
if (existing && existing.value === '7') {
|
||
// Migration v7 → v8: seed `meta` rows for operator-config consumer.
|
||
// - lastKnownConfigCreatedAt — replay-protection watermark. Drop any
|
||
// incoming kind-30078 operator-config event whose created_at is
|
||
// ≤ this value. Default 0 = "haven't applied anything yet."
|
||
// - bootstrapPublishedAt — gate for the one-shot ATM-side
|
||
// bitspire-cassettes-state hello-event. NULL = "haven't published
|
||
// bootstrap yet"; once set, the hello-event publish path becomes
|
||
// a no-op (continuous reverse-channel publish is v2 territory).
|
||
// See aiolabs/lamassu-next#56 + ~/dev/coordination/log.md (2026-05-30).
|
||
db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('lastKnownConfigCreatedAt', '0')
|
||
db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('bootstrapPublishedAt', '')
|
||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('8', 'schema_version')
|
||
console.log('[StateStore] Migrated schema v7 → v8 (seeded operator-config meta rows)')
|
||
existing.value = '8'
|
||
}
|
||
|
||
if (existing && existing.value === '8') {
|
||
// Migration v8 → v9: rebuild `cassettes` so `position` is the PK, NOT
|
||
// `denomination`. Real production machines (Tejo, batm3) load multiple
|
||
// cassettes with the same denomination for cash-out throughput on a
|
||
// single bill class — the v1.0 schema's denomination-PK silently
|
||
// collapsed duplicates and the HAL `indexOf(denomination)` first-match
|
||
// semantics meant only one bay of any given denom could ever dispense.
|
||
// v9 makes `position` the addressable unit (matches the hardware bay
|
||
// layout) and allows `denomination` to repeat across rows.
|
||
//
|
||
// The cassette-config wire shape (operator → ATM kind-30078) flips
|
||
// alongside this from `{denominations: {...}}` → `{positions: {...}}`.
|
||
// See aiolabs/lamassu-next#56 + ~/dev/coordination/log.md 2026-05-30
|
||
// entries (06:30Z → 18:45Z) for the design history.
|
||
db.pragma('foreign_keys = OFF')
|
||
db.exec(`
|
||
DROP TABLE IF EXISTS cassettes_new;
|
||
CREATE TABLE cassettes_new (
|
||
position INTEGER PRIMARY KEY,
|
||
denomination INTEGER NOT NULL,
|
||
count INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
INSERT INTO cassettes_new (position, denomination, count)
|
||
SELECT position, denomination, count FROM cassettes;
|
||
DROP TABLE cassettes;
|
||
ALTER TABLE cassettes_new RENAME TO cassettes;
|
||
`)
|
||
db.pragma('foreign_keys = ON')
|
||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('9', 'schema_version')
|
||
console.log(
|
||
'[StateStore] Migrated schema v8 → v9 (cassettes PK position; allow duplicate denominations)'
|
||
)
|
||
existing.value = '9'
|
||
}
|
||
|
||
if (existing && existing.value === '9') {
|
||
// Migration v9 → v10: operator-pushed fee config consumer
|
||
// (aiolabs/lamassu-next#57).
|
||
// - fee_config table — singleton row (id=1) carrying the last
|
||
// applied cash-in/cash-out fee fractions. Persisted so a restart
|
||
// restores the operator's policy without waiting on relay. The
|
||
// super/operator breakdown is NOT mirrored here — satmachineadmin
|
||
// is the canonical audit substrate for the split per settlement
|
||
// (Layer 1 #38). See coord log 2026-06-01T07:56Z for the dumb-
|
||
// machine/smart-server rationale (the components stay on the
|
||
// wire — they get parser-side consistency-asserted + logged at
|
||
// receipt — but don't propagate beyond the parse boundary).
|
||
// - meta.lastKnownFeeConfigCreatedAt — replay-protection watermark
|
||
// (separate from `lastKnownConfigCreatedAt` for cassettes, per
|
||
// d-tag-per-lifecycle convention). Default 0 = "fresh ATM,
|
||
// nothing applied yet → fail-closed into maintenance screen."
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS fee_config (
|
||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||
cash_in_fee_fraction REAL NOT NULL,
|
||
cash_out_fee_fraction REAL NOT NULL,
|
||
schema_version INTEGER NOT NULL DEFAULT 1,
|
||
event_created_at INTEGER NOT NULL,
|
||
applied_at INTEGER NOT NULL
|
||
);
|
||
`)
|
||
db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)').run(
|
||
'lastKnownFeeConfigCreatedAt',
|
||
'0'
|
||
)
|
||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('10', 'schema_version')
|
||
console.log('[StateStore] Migrated schema v9 → v10 (added fee_config + watermark)')
|
||
existing.value = '10'
|
||
}
|
||
|
||
if (existing && existing.value === '10') {
|
||
// Migration v10 → v11: NIP-46 bunker binding (aiolabs/bitspire#52).
|
||
// - bunker_binding singleton — the ATM's own NIP-46 transport key
|
||
// (client_nsec) plus the spire signing identity, bunker URL, and a
|
||
// fingerprint of the seed it was paired from. Persisted so a restart
|
||
// resumes the bunker session without re-redeeming the one-shot connect
|
||
// secret. A new/changed seed_fingerprint signals a re-pair (which also
|
||
// resets bootstrapPublishedAt — see lightning.ts / bitspire#56).
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS bunker_binding (
|
||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||
client_secret_hex TEXT NOT NULL,
|
||
spire_pubkey TEXT NOT NULL,
|
||
bunker_url TEXT NOT NULL,
|
||
seed_fingerprint TEXT NOT NULL,
|
||
paired_at INTEGER NOT NULL
|
||
);
|
||
`)
|
||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('11', 'schema_version')
|
||
console.log('[StateStore] Migrated schema v10 → v11 (added bunker_binding)')
|
||
existing.value = '11'
|
||
}
|
||
|
||
if (existing && existing.value === '11') {
|
||
// Migration v11 → v12: carry the LNbits transport config in the binding
|
||
// (aiolabs/bitspire#70). relays (JSON array) + lnbits_server_pubkey let a
|
||
// paired machine reach the backend from the pairing alone — no VITE_RELAY_URL
|
||
// / VITE_LNBITS_SERVER_PUBKEY provisioning. Nullable: bindings written before
|
||
// this (the seed didn't carry them) resume fine and fall back to env.
|
||
db.exec(`
|
||
ALTER TABLE bunker_binding ADD COLUMN relays TEXT;
|
||
ALTER TABLE bunker_binding ADD COLUMN lnbits_server_pubkey TEXT;
|
||
`)
|
||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('12', 'schema_version')
|
||
console.log('[StateStore] Migrated schema v11 → v12 (bunker_binding transport config)')
|
||
}
|
||
|
||
if (existing && existing.value === '12') {
|
||
// Migration v12 → v13: operator OPERATIONS replace operator counts
|
||
// (aiolabs/bitspire ADR-004).
|
||
//
|
||
// The operator used to publish absolute counts and this machine applied
|
||
// them outright. Both sides wrote the same value over a transport that
|
||
// never tells a writer it lost, so a dashboard form loaded before a
|
||
// dispense silently discarded that dispense — and nothing on either side
|
||
// could detect it afterwards. The operator now publishes what it DID and
|
||
// this machine, which holds the notes, owns the running total.
|
||
//
|
||
// `cassette_ops` is the dedup ledger. A delta applied twice is wrong, and
|
||
// addressable events are re-delivered on every reconnect, so the operator
|
||
// mints an id per operation and we record the ones we have applied. The
|
||
// operator's window is a slice of recent operations rather than just the
|
||
// newest, so one we missed arrives with the next publish; dedup is what
|
||
// makes re-delivery free instead of dangerous.
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS cassette_ops (
|
||
id TEXT PRIMARY KEY,
|
||
position INTEGER NOT NULL,
|
||
op_type TEXT NOT NULL,
|
||
bills INTEGER,
|
||
count INTEGER,
|
||
denomination INTEGER,
|
||
op_at INTEGER NOT NULL,
|
||
applied_at INTEGER NOT NULL
|
||
);
|
||
`)
|
||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('13', 'schema_version')
|
||
console.log('[StateStore] Migrated schema v12 → v13 (added cassette_ops)')
|
||
existing.value = '13'
|
||
}
|
||
|
||
if (existing && existing.value === '13') {
|
||
// Migration v13 → v14: the dispense-report outbox (ADR-005 §2).
|
||
//
|
||
// Every cash-out's outcome — success or failure — is reported to
|
||
// spirekeeper over a kind-21000 RPC, and that report is what lets the
|
||
// server capture (distribute) the settlement or surface a customer who
|
||
// is owed cash. A relay gives the publisher no delivery guarantee, so the
|
||
// report is written here, in the SAME transaction as the transactions
|
||
// row, and resent until the server acknowledges it. Idempotent on txid
|
||
// server-side; `attempts` / `last_error` drive the resend backoff.
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS dispense_reports (
|
||
txid TEXT PRIMARY KEY REFERENCES transactions(txid),
|
||
payload TEXT NOT NULL,
|
||
created_at INTEGER NOT NULL,
|
||
attempts INTEGER NOT NULL DEFAULT 0,
|
||
last_attempt_at INTEGER,
|
||
last_error TEXT,
|
||
acked_at INTEGER
|
||
);
|
||
`)
|
||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('14', 'schema_version')
|
||
console.log('[StateStore] Migrated schema v13 → v14 (added dispense_reports outbox)')
|
||
existing.value = '14'
|
||
}
|
||
|
||
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
|
||
// Seed the operator-config meta rows if they're missing (idempotent).
|
||
const seedMeta = db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)')
|
||
seedMeta.run('lastKnownConfigCreatedAt', '0')
|
||
seedMeta.run('bootstrapPublishedAt', '')
|
||
seedMeta.run('lastKnownFeeConfigCreatedAt', '0')
|
||
seedMeta.run('cassetteStateSeq', '0')
|
||
|
||
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
|
||
if (!cashboxRow) {
|
||
db.prepare('INSERT INTO cashbox (id) VALUES (1)').run()
|
||
}
|
||
|
||
console.log('[StateStore] Initialized database at', resolvedPath)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Meta — operator-config consumer state
|
||
// (lastKnownConfigCreatedAt + bootstrapPublishedAt for aiolabs/lamassu-next#56)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Read replay-protection watermark. Returns 0 if no operator config has
|
||
* been applied yet (fresh ATM, pre-bootstrap).
|
||
*/
|
||
export function getLastKnownConfigCreatedAt(): number {
|
||
if (!db) throw new Error('Database not initialized')
|
||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('lastKnownConfigCreatedAt') as
|
||
| { value: string }
|
||
| undefined
|
||
return row ? Number(row.value) || 0 : 0
|
||
}
|
||
|
||
/**
|
||
* The `created_at` of the last `bitspire-cassettes-state` event this machine
|
||
* published, or null if it has never published one.
|
||
*
|
||
* This used to be a one-shot gate ("have we said hello yet"), which meant a
|
||
* layout change after first boot was never announced (#94). It is now a
|
||
* high-water mark: every publish records its stamp, and the next one is forced
|
||
* strictly above it. Addressable events are ordered by `created_at` at second
|
||
* granularity, and a relay silently keeps the higher one, so a clock that steps
|
||
* backwards would otherwise make this machine's reports vanish with an `OK`.
|
||
*
|
||
* Stored under the original `bootstrapPublishedAt` meta key so no migration is
|
||
* needed; the name is historical, the meaning is not.
|
||
*/
|
||
export function getLastStatePublishedAt(): number | null {
|
||
if (!db) throw new Error('Database not initialized')
|
||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('bootstrapPublishedAt') as
|
||
| { value: string }
|
||
| undefined
|
||
if (!row || row.value === '') return null
|
||
const n = Number(row.value)
|
||
return Number.isFinite(n) ? n : null
|
||
}
|
||
|
||
/**
|
||
* Whether the bay counts are known to be unverified, and since when.
|
||
*
|
||
* Set when a dispense ends without the dispenser reporting what it moved — a
|
||
* driver throw, or the dispense timeout. Bills may well have reached the
|
||
* customer, but nothing knows how many, so neither the rows here nor HAL's
|
||
* bays were debited and both now read high. Reporting that number as fact is
|
||
* the worst option available; saying the number is unverified is honest and
|
||
* tells the operator to open the machine and recount.
|
||
*
|
||
* Cleared when an operator asserts authoritative counts (a config apply),
|
||
* which is precisely what a recount is. Uses an upsert so no migration is
|
||
* needed for machines whose meta table predates the key.
|
||
*/
|
||
export function getCountsUncertainSince(): number | null {
|
||
if (!db) throw new Error('Database not initialized')
|
||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('countsUncertainSince') as
|
||
| { value: string }
|
||
| undefined
|
||
if (!row || row.value === '') return null
|
||
const n = Number(row.value)
|
||
return Number.isFinite(n) ? n : null
|
||
}
|
||
|
||
/** Flag the counts as unverified. Keeps the earliest time it went bad. */
|
||
export function markCountsUncertain(unixTimestamp: number): void {
|
||
if (!db) throw new Error('Database not initialized')
|
||
if (getCountsUncertainSince() !== null) return
|
||
db.prepare(
|
||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||
).run('countsUncertainSince', String(unixTimestamp))
|
||
console.warn('[StateStore] Cassette counts flagged unverified at', unixTimestamp)
|
||
}
|
||
|
||
/** Clear the flag — an operator has asserted real counts. */
|
||
export function clearCountsUncertain(): void {
|
||
if (!db) throw new Error('Database not initialized')
|
||
db.prepare(
|
||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||
).run('countsUncertainSince', '')
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Cash-out hold (ADR-005 §5)
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// A terminal dispenser fault latches cash-out off. The latch is machine
|
||
// health, so it lives in `meta` (one JSON value) and survives restarts; the
|
||
// renderer restores it into the state machine on boot and the operator
|
||
// releases it with a `recount` or `resume_cash_out` op. Re-initialising the
|
||
// dispenser never clears it — re-init does not move a stuck note.
|
||
|
||
export interface CashOutHold {
|
||
reason: string
|
||
errorCode: string | null
|
||
rawCode: string | null
|
||
/** unix seconds of the FIRST fault — kept across repeat faults */
|
||
since: number
|
||
}
|
||
|
||
export function getCashOutHold(): CashOutHold | null {
|
||
if (!db) throw new Error('Database not initialized')
|
||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('cashOutHeld') as
|
||
| { value: string }
|
||
| undefined
|
||
if (!row || row.value === '') return null
|
||
try {
|
||
const parsed = JSON.parse(row.value) as Partial<CashOutHold>
|
||
if (typeof parsed.since !== 'number' || typeof parsed.reason !== 'string') return null
|
||
return {
|
||
reason: parsed.reason,
|
||
errorCode: typeof parsed.errorCode === 'string' ? parsed.errorCode : null,
|
||
rawCode: typeof parsed.rawCode === 'string' ? parsed.rawCode : null,
|
||
since: parsed.since,
|
||
}
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/** Latch cash-out off. Idempotent: an existing hold (and its `since`) is kept. */
|
||
export function setCashOutHold(hold: CashOutHold): CashOutHold {
|
||
if (!db) throw new Error('Database not initialized')
|
||
const existing = getCashOutHold()
|
||
if (existing) return existing
|
||
db.prepare(
|
||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||
).run('cashOutHeld', JSON.stringify(hold))
|
||
console.warn(
|
||
`[StateStore] Cash-out HELD: ${hold.errorCode ?? 'fault'}${hold.rawCode ? ` ${hold.rawCode}` : ''} — ${hold.reason}`
|
||
)
|
||
return hold
|
||
}
|
||
|
||
/** Release the latch — an operator has cleared the machine. */
|
||
export function clearCashOutHold(): boolean {
|
||
if (!db) throw new Error('Database not initialized')
|
||
const had = getCashOutHold() !== null
|
||
db.prepare(
|
||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||
).run('cashOutHeld', '')
|
||
if (had) console.log('[StateStore] Cash-out hold released')
|
||
return had
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Dispense-report outbox (ADR-005 §2)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface PendingDispenseReport {
|
||
txid: string
|
||
payload: DispenseReportBody
|
||
createdAt: number
|
||
attempts: number
|
||
lastAttemptAt: number | null
|
||
lastError: string | null
|
||
}
|
||
|
||
/** Unacknowledged reports, oldest first. The renderer applies the backoff. */
|
||
export function pendingDispenseReports(limit = 20): PendingDispenseReport[] {
|
||
if (!db) throw new Error('Database not initialized')
|
||
const rows = db
|
||
.prepare(
|
||
'SELECT txid, payload, created_at, attempts, last_attempt_at, last_error FROM dispense_reports WHERE acked_at IS NULL ORDER BY created_at ASC LIMIT ?'
|
||
)
|
||
.all(limit) as Array<{
|
||
txid: string
|
||
payload: string
|
||
created_at: number
|
||
attempts: number
|
||
last_attempt_at: number | null
|
||
last_error: string | null
|
||
}>
|
||
const out: PendingDispenseReport[] = []
|
||
for (const r of rows) {
|
||
try {
|
||
out.push({
|
||
txid: r.txid,
|
||
payload: JSON.parse(r.payload) as DispenseReportBody,
|
||
createdAt: r.created_at,
|
||
attempts: r.attempts,
|
||
lastAttemptAt: r.last_attempt_at,
|
||
lastError: r.last_error,
|
||
})
|
||
} catch {
|
||
console.error('[StateStore] dispense_reports row has unparseable payload:', r.txid)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
/** The server acknowledged this report. Returns whether a row changed. */
|
||
export function markDispenseReportAcked(txid: string): boolean {
|
||
if (!db) throw new Error('Database not initialized')
|
||
const res = db
|
||
.prepare('UPDATE dispense_reports SET acked_at = ? WHERE txid = ? AND acked_at IS NULL')
|
||
.run(Date.now(), txid)
|
||
if (res.changes > 0) console.log('[StateStore] Dispense report acked:', txid)
|
||
return res.changes > 0
|
||
}
|
||
|
||
/** A send was attempted and did not get an OK. Drives the resend backoff. */
|
||
export function noteDispenseReportAttempt(txid: string, error: string | null): void {
|
||
if (!db) throw new Error('Database not initialized')
|
||
db.prepare(
|
||
'UPDATE dispense_reports SET attempts = attempts + 1, last_attempt_at = ?, last_error = ? WHERE txid = ?'
|
||
).run(Date.now(), error, txid)
|
||
}
|
||
|
||
/**
|
||
* A counter bumped on every local change to a bay count, from any cause.
|
||
*
|
||
* It rides along in the state document so a reader can reject a regression
|
||
* without trusting a clock. `created_at` cannot carry that: it has
|
||
* second granularity, so two publishes in the same second are ordered by
|
||
* whichever event id hashes lower — and a machine whose clock stepped
|
||
* backwards would otherwise have every later report look older than the one
|
||
* already on the relay.
|
||
*/
|
||
export function getCassetteStateSeq(): number {
|
||
if (!db) throw new Error('Database not initialized')
|
||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('cassetteStateSeq') as
|
||
| { value: string }
|
||
| undefined
|
||
return row ? Number(row.value) || 0 : 0
|
||
}
|
||
|
||
/**
|
||
* Bump the counter. Safe to call inside an open transaction — every caller
|
||
* that mutates a count does, so the bump commits or rolls back with it.
|
||
*/
|
||
export function bumpCassetteStateSeq(): void {
|
||
if (!db) throw new Error('Database not initialized')
|
||
db.prepare(
|
||
'INSERT INTO meta (key, value) VALUES (?, ?) ' +
|
||
'ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(meta.value AS INTEGER) + 1 AS TEXT)'
|
||
).run('cassetteStateSeq', '1')
|
||
}
|
||
|
||
/** Record the `created_at` just published, as the next publish's floor. */
|
||
export function markStatePublished(unixTimestamp: number): void {
|
||
if (!db) throw new Error('Database not initialized')
|
||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run(
|
||
String(unixTimestamp),
|
||
'bootstrapPublishedAt'
|
||
)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Bunker binding — NIP-46 transport key + spire identity (aiolabs/bitspire#52)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface StoredBunkerBinding {
|
||
/** The ATM's own NIP-46 transport secret key (`client_nsec`), hex. */
|
||
clientSecretHex: string
|
||
/** The spire's signing pubkey (hex) — the identity events are signed as. */
|
||
spirePubkey: string
|
||
/** `bunker://…` URL, re-parsed into a pointer on resume. */
|
||
bunkerUrl: string
|
||
/** Fingerprint of the seed this binding was paired from (re-pair detection). */
|
||
seedFingerprint: string
|
||
/** Unix seconds when the pairing was redeemed. */
|
||
pairedAt: number
|
||
/**
|
||
* LNbits transport relays from the pairing seed (aiolabs/bitspire#70). Lets a
|
||
* resumed (seedless) boot reach the backend without env provisioning.
|
||
* Undefined for bindings written before the seed carried them.
|
||
*/
|
||
relays?: string[]
|
||
/** LNbits nostr-transport server pubkey (hex) from the seed (#70). */
|
||
lnbitsServerPubkey?: string
|
||
}
|
||
|
||
/** Read the persisted bunker binding, or null if the ATM is unpaired. */
|
||
export function getBunkerBinding(): StoredBunkerBinding | null {
|
||
if (!db) throw new Error('Database not initialized')
|
||
const row = db
|
||
.prepare(
|
||
'SELECT client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey FROM bunker_binding WHERE id = 1'
|
||
)
|
||
.get() as
|
||
| {
|
||
client_secret_hex: string
|
||
spire_pubkey: string
|
||
bunker_url: string
|
||
seed_fingerprint: string
|
||
paired_at: number
|
||
relays: string | null
|
||
lnbits_server_pubkey: string | null
|
||
}
|
||
| undefined
|
||
if (!row) return null
|
||
return {
|
||
clientSecretHex: row.client_secret_hex,
|
||
spirePubkey: row.spire_pubkey,
|
||
bunkerUrl: row.bunker_url,
|
||
seedFingerprint: row.seed_fingerprint,
|
||
pairedAt: row.paired_at,
|
||
relays: parseRelaysColumn(row.relays),
|
||
lnbitsServerPubkey: row.lnbits_server_pubkey ?? undefined,
|
||
}
|
||
}
|
||
|
||
/** Decode the JSON-array `relays` column, tolerating null/legacy/garbage. */
|
||
function parseRelaysColumn(value: string | null): string[] | undefined {
|
||
if (!value) return undefined
|
||
try {
|
||
const parsed = JSON.parse(value)
|
||
if (Array.isArray(parsed) && parsed.every((r) => typeof r === 'string')) {
|
||
return parsed as string[]
|
||
}
|
||
} catch {
|
||
// fall through
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
/** Upsert the bunker binding after a successful (re-)pairing. */
|
||
export function saveBunkerBinding(binding: StoredBunkerBinding): void {
|
||
if (!db) throw new Error('Database not initialized')
|
||
db.prepare(
|
||
`INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey)
|
||
VALUES (1, ?, ?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(id) DO UPDATE SET
|
||
client_secret_hex = excluded.client_secret_hex,
|
||
spire_pubkey = excluded.spire_pubkey,
|
||
bunker_url = excluded.bunker_url,
|
||
seed_fingerprint = excluded.seed_fingerprint,
|
||
paired_at = excluded.paired_at,
|
||
relays = excluded.relays,
|
||
lnbits_server_pubkey = excluded.lnbits_server_pubkey`
|
||
).run(
|
||
binding.clientSecretHex,
|
||
binding.spirePubkey,
|
||
binding.bunkerUrl,
|
||
binding.seedFingerprint,
|
||
binding.pairedAt,
|
||
binding.relays ? JSON.stringify(binding.relays) : null,
|
||
binding.lnbitsServerPubkey ?? null
|
||
)
|
||
}
|
||
|
||
/** Drop the bunker binding (e.g. after an operator revoke → force re-pair). */
|
||
export function clearBunkerBinding(): void {
|
||
if (!db) throw new Error('Database not initialized')
|
||
db.prepare('DELETE FROM bunker_binding WHERE id = 1').run()
|
||
}
|
||
|
||
/**
|
||
* Forget the publish high-water mark. Called on a re-pair (new seed): the
|
||
* next publish is then free to use the wall clock, which is what a fresh
|
||
* operator relationship wants. The state itself is republished on startup
|
||
* regardless, so the new operator always receives current counts.
|
||
*/
|
||
export function resetStatePublishWatermark(): void {
|
||
if (!db) throw new Error('Database not initialized')
|
||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt')
|
||
}
|
||
|
||
/**
|
||
* Wipe operator-scoped CONFIG/TRUST state on a re-pair to a new operator/backend,
|
||
* so stale policy from the previous pairing can't linger or silently reject the
|
||
* new operator's config.
|
||
*
|
||
* Clears the fee config and resets BOTH replay watermarks to 0. The watermark
|
||
* reset is the load-bearing part: without it, a new backend whose first config
|
||
* event has a lower `created_at` than the old operator's last event is silently
|
||
* dropped as a replay — the exact remnant trap where re-pairing a long-lived
|
||
* install to a fresh backend appears to "work" but never picks up new config.
|
||
*
|
||
* Deliberately does NOT touch cassettes / cashbox / transactions: those track
|
||
* PHYSICAL cash, which survives an operator handover. A full wipe (decommission
|
||
* or a truly-fresh test) is the factory-reset path, not this.
|
||
*/
|
||
export function resetForRepair(): void {
|
||
if (!db) throw new Error('Database not initialized')
|
||
const database = db
|
||
database.transaction(() => {
|
||
database.prepare('DELETE FROM fee_config').run()
|
||
const setWatermark = database.prepare('UPDATE meta SET value = ? WHERE key = ?')
|
||
setWatermark.run('0', 'lastKnownFeeConfigCreatedAt')
|
||
setWatermark.run('0', 'lastKnownConfigCreatedAt')
|
||
})()
|
||
}
|
||
|
||
/**
|
||
* Outcome of applying an operator-authored absolute config. Still the right
|
||
* shape for fee config, where the operator is the only writer of the value
|
||
* and a later event simply supersedes an earlier one. Cassette counts left
|
||
* this model in ADR-004 precisely because they had two writers.
|
||
*/
|
||
export type ApplyResult = { applied: true } | { applied: false; reason: string }
|
||
|
||
/** One operator-authored operation, as it arrives on the wire. */
|
||
export type CassetteOp = {
|
||
id: string
|
||
at: number
|
||
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
|
||
position: number
|
||
bills?: number
|
||
count?: number
|
||
denomination?: number
|
||
}
|
||
|
||
export type ApplyOpsResult = {
|
||
/** Ids applied by this call. Empty when every op was already on file. */
|
||
applied: string[]
|
||
/** Ids rejected, with why. These stay unapplied and unrecorded. */
|
||
rejected: { id: string; reason: string }[]
|
||
}
|
||
|
||
const CASSETTE_OP_TYPES = new Set(['refill', 'empty', 'recount', 'set_denomination'])
|
||
|
||
/**
|
||
* Validate one operation in isolation. Returns null when it is well-formed.
|
||
*
|
||
* Shape errors and unknown positions are treated the same way by the caller:
|
||
* the op is neither applied nor recorded, so it stays pending on the
|
||
* operator's dashboard. That is the honest outcome — it did not happen — and
|
||
* it beats recording it as applied to stop the noise, which would tell the
|
||
* operator their refill landed when the notes are unaccounted for.
|
||
*/
|
||
function validateCassetteOp(op: CassetteOp, knownPositions: Set<number>): string | null {
|
||
if (typeof op.id !== 'string' || op.id.length === 0) return 'missing id'
|
||
if (!CASSETTE_OP_TYPES.has(op.type)) return `unknown type ${String(op.type)}`
|
||
if (!Number.isInteger(op.position)) return `position must be an integer (got ${op.position})`
|
||
if (!knownPositions.has(op.position)) return `unknown position ${op.position}`
|
||
if (!Number.isFinite(op.at)) return 'missing at'
|
||
|
||
if (op.type === 'refill') {
|
||
if (!Number.isInteger(op.bills) || (op.bills as number) <= 0) {
|
||
return `refill needs a positive integer bills (got ${op.bills})`
|
||
}
|
||
}
|
||
if (op.type === 'recount') {
|
||
if (!Number.isInteger(op.count) || (op.count as number) < 0) {
|
||
return `recount needs a non-negative integer count (got ${op.count})`
|
||
}
|
||
}
|
||
if (op.type === 'set_denomination') {
|
||
if (!Number.isInteger(op.denomination) || (op.denomination as number) <= 0) {
|
||
return `set_denomination needs a positive integer denomination (got ${op.denomination})`
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Apply an operator's cassette operations, skipping any already on file.
|
||
*
|
||
* This replaces applying absolute counts. The operator authors what it DID —
|
||
* a refill in notes added, an empty, a recount, a denomination change — and
|
||
* this machine, which holds the physical notes, keeps the running total.
|
||
* Nobody but this process writes a count any more, so there is no second
|
||
* writer to lose a race to.
|
||
*
|
||
* Deltas are not idempotent and addressable events ARE re-delivered on every
|
||
* relay reconnect, so idempotency is carried explicitly: the operator mints an
|
||
* id per operation, `cassette_ops` records the ones applied, and a repeat is a
|
||
* no-op. That is also why there is no `created_at` watermark here any more.
|
||
* Under absolute counts the watermark was the only replay defence; with
|
||
* per-op ids it is strictly weaker than the dedup and would do active harm,
|
||
* because an event that arrives out of order may still carry an operation this
|
||
* machine has never seen.
|
||
*
|
||
* Applied oldest-first by `at`, ties broken by id so two operations stamped in
|
||
* the same second still order the same way on every machine. Ordering matters
|
||
* because a recount followed by a refill is not the same as the reverse.
|
||
*
|
||
* The whole batch runs in one SQLite transaction with the sequence bump, so a
|
||
* crash mid-apply rolls back to a coherent count and the next publish re-offers
|
||
* every op in the window.
|
||
*/
|
||
export function applyOperatorCassetteOps(ops: CassetteOp[]): ApplyOpsResult {
|
||
if (!db) throw new Error('Database not initialized')
|
||
const database = db
|
||
const result: ApplyOpsResult = { applied: [], rejected: [] }
|
||
if (ops.length === 0) return result
|
||
|
||
const knownPositions = new Set(
|
||
(database.prepare('SELECT position FROM cassettes').all() as { position: number }[]).map(
|
||
(r) => r.position
|
||
)
|
||
)
|
||
const seen = database.prepare('SELECT 1 FROM cassette_ops WHERE id = ?')
|
||
|
||
const pending: CassetteOp[] = []
|
||
for (const op of ops) {
|
||
if (op && typeof op.id === 'string' && seen.get(op.id)) continue
|
||
const reason = validateCassetteOp(op, knownPositions)
|
||
if (reason) {
|
||
result.rejected.push({ id: op?.id ?? '<no id>', reason })
|
||
continue
|
||
}
|
||
pending.push(op)
|
||
}
|
||
if (pending.length === 0) return result
|
||
|
||
pending.sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||
|
||
const addBills = database.prepare(
|
||
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?'
|
||
)
|
||
const setCount = database.prepare('UPDATE cassettes SET count = ? WHERE position = ?')
|
||
const setDenomination = database.prepare(
|
||
'UPDATE cassettes SET denomination = ? WHERE position = ?'
|
||
)
|
||
const recordOp = database.prepare(
|
||
'INSERT INTO cassette_ops (id, position, op_type, bills, count, denomination, op_at, applied_at) ' +
|
||
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||
)
|
||
const upsertMeta = database.prepare(
|
||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||
)
|
||
|
||
const appliedAt = Math.floor(Date.now() / 1000)
|
||
let sawRecount = false
|
||
|
||
database.transaction(() => {
|
||
for (const op of pending) {
|
||
if (op.type === 'refill') addBills.run(op.bills, op.position)
|
||
else if (op.type === 'empty') setCount.run(0, op.position)
|
||
else if (op.type === 'recount') {
|
||
setCount.run(op.count, op.position)
|
||
sawRecount = true
|
||
} else setDenomination.run(op.denomination, op.position)
|
||
|
||
recordOp.run(
|
||
op.id,
|
||
op.position,
|
||
op.type,
|
||
op.bills ?? null,
|
||
op.count ?? null,
|
||
op.denomination ?? null,
|
||
Math.floor(op.at),
|
||
appliedAt
|
||
)
|
||
result.applied.push(op.id)
|
||
}
|
||
bumpCassetteStateSeq()
|
||
// A recount is an operator opening the bay and counting it, which is
|
||
// exactly what resolves an unverified count. Nothing else does: a refill
|
||
// adds to a number still known to be wrong.
|
||
if (sawRecount) {
|
||
upsertMeta.run('countsUncertainSince', '')
|
||
// ADR-005 §5: a recount is an operator at the open machine — the one
|
||
// gesture that also releases a cash-out hold.
|
||
upsertMeta.run('cashOutHeld', '')
|
||
}
|
||
})()
|
||
|
||
console.log(
|
||
`[StateStore] Applied ${result.applied.length} cassette op(s)` +
|
||
(result.rejected.length ? `, rejected ${result.rejected.length}` : '')
|
||
)
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* The ids most recently applied, newest first — the acknowledgement leg of
|
||
* the protocol.
|
||
*
|
||
* An addressable event gives its publisher no failure signal at all: the relay
|
||
* returns OK for an event it then discards, and a losing writer is never told.
|
||
* Echoing the ids back in this machine's own state document is the only way
|
||
* the operator can distinguish an operation that landed from one that was
|
||
* merely sent.
|
||
*/
|
||
export function getAppliedOpIds(limit = 50): string[] {
|
||
if (!db) throw new Error('Database not initialized')
|
||
const rows = db
|
||
.prepare('SELECT id FROM cassette_ops ORDER BY applied_at DESC, rowid DESC LIMIT ?')
|
||
.all(limit) as { id: string }[]
|
||
return rows.map((r) => r.id)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Fee config — operator-pushed fee fractions (aiolabs/lamassu-next#57)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface FeeConfigRow {
|
||
cashInFeeFraction: number
|
||
cashOutFeeFraction: number
|
||
schemaVersion: number
|
||
/** Watermark — the event's created_at (NIP-78 replaceable event timestamp). */
|
||
eventCreatedAt: number
|
||
/** Local Date.now() at the moment this row was upserted via IPC. Triage primitive. */
|
||
appliedAt: number
|
||
}
|
||
|
||
/**
|
||
* Read the last-applied operator fee config. Returns null when no event
|
||
* has ever been applied (fresh ATM, pre-operator-publish). The renderer
|
||
* uses null → maintenance screen ("Awaiting fee configuration from
|
||
* operator") per the fail-closed posture from issue #57.
|
||
*/
|
||
export function getFeeConfig(): FeeConfigRow | null {
|
||
if (!db) throw new Error('Database not initialized')
|
||
const row = db
|
||
.prepare(
|
||
'SELECT cash_in_fee_fraction, cash_out_fee_fraction, schema_version, event_created_at, applied_at FROM fee_config WHERE id = 1'
|
||
)
|
||
.get() as
|
||
| {
|
||
cash_in_fee_fraction: number
|
||
cash_out_fee_fraction: number
|
||
schema_version: number
|
||
event_created_at: number
|
||
applied_at: number
|
||
}
|
||
| undefined
|
||
if (!row) return null
|
||
return {
|
||
cashInFeeFraction: row.cash_in_fee_fraction,
|
||
cashOutFeeFraction: row.cash_out_fee_fraction,
|
||
schemaVersion: row.schema_version,
|
||
eventCreatedAt: row.event_created_at,
|
||
appliedAt: row.applied_at,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Read replay-protection watermark. Returns 0 if no fee config has ever
|
||
* been applied. Separate from `lastKnownConfigCreatedAt` (cassettes) per
|
||
* the d-tag-per-lifecycle convention — independent watermarks isolate
|
||
* blast radius across operator-pushed config types.
|
||
*/
|
||
export function getLastKnownFeeConfigCreatedAt(): number {
|
||
if (!db) throw new Error('Database not initialized')
|
||
const row = db
|
||
.prepare('SELECT value FROM meta WHERE key = ?')
|
||
.get('lastKnownFeeConfigCreatedAt') as { value: string } | undefined
|
||
return row ? Number(row.value) || 0 : 0
|
||
}
|
||
|
||
export interface FeeConfigPayload {
|
||
cashInFeeFraction: number
|
||
cashOutFeeFraction: number
|
||
schemaVersion: number
|
||
}
|
||
|
||
/**
|
||
* Atomic apply of an operator-published fee config (aiolabs/lamassu-next#57).
|
||
*
|
||
* Caller has already verified the event signature, decrypted the content,
|
||
* enforced the per-direction 15% cap, and ignored any unknown top-level
|
||
* keys (v2 forward-compat). This function:
|
||
*
|
||
* 1. Rechecks replay-protection against `meta.lastKnownFeeConfigCreatedAt`
|
||
* (defense-in-depth — caller should have done this too).
|
||
* 2. Re-validates both fractions are in [0, 0.15] (defense in depth — the
|
||
* renderer-side cap is the primary check; the state-store guard catches
|
||
* bypass via a buggy or tampered renderer).
|
||
* 3. In a single SQLite transaction: upserts the singleton fee_config row
|
||
* AND advances `meta.lastKnownFeeConfigCreatedAt` to `eventCreatedAt`.
|
||
*/
|
||
const FEE_CAP_PER_DIRECTION = 0.15
|
||
|
||
export function applyFeeConfig(payload: FeeConfigPayload, eventCreatedAt: number): ApplyResult {
|
||
if (!db) throw new Error('Database not initialized')
|
||
|
||
const watermark = getLastKnownFeeConfigCreatedAt()
|
||
if (eventCreatedAt <= watermark) {
|
||
return {
|
||
applied: false,
|
||
reason: `event.created_at (${eventCreatedAt}) <= lastKnownFeeConfigCreatedAt (${watermark})`,
|
||
}
|
||
}
|
||
|
||
const rangeChecks: [string, number][] = [
|
||
['cash_in_fee_fraction', payload.cashInFeeFraction],
|
||
['cash_out_fee_fraction', payload.cashOutFeeFraction],
|
||
]
|
||
for (const [name, value] of rangeChecks) {
|
||
if (!Number.isFinite(value) || value < 0 || value > FEE_CAP_PER_DIRECTION) {
|
||
return {
|
||
applied: false,
|
||
reason: `${name} out of range [0, ${FEE_CAP_PER_DIRECTION}]: ${value}`,
|
||
}
|
||
}
|
||
}
|
||
if (!Number.isInteger(payload.schemaVersion) || payload.schemaVersion < 1) {
|
||
return { applied: false, reason: `invalid schema_version: ${payload.schemaVersion}` }
|
||
}
|
||
if (!Number.isInteger(eventCreatedAt) || eventCreatedAt < 0) {
|
||
return { applied: false, reason: `invalid event_created_at: ${eventCreatedAt}` }
|
||
}
|
||
|
||
const upsert = db.prepare(
|
||
'INSERT INTO fee_config (id, cash_in_fee_fraction, cash_out_fee_fraction, schema_version, event_created_at, applied_at) VALUES (1, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET cash_in_fee_fraction = excluded.cash_in_fee_fraction, cash_out_fee_fraction = excluded.cash_out_fee_fraction, schema_version = excluded.schema_version, event_created_at = excluded.event_created_at, applied_at = excluded.applied_at'
|
||
)
|
||
const setWatermark = db.prepare('UPDATE meta SET value = ? WHERE key = ?')
|
||
|
||
const run = db.transaction(() => {
|
||
upsert.run(
|
||
payload.cashInFeeFraction,
|
||
payload.cashOutFeeFraction,
|
||
payload.schemaVersion,
|
||
eventCreatedAt,
|
||
Date.now()
|
||
)
|
||
setWatermark.run(String(eventCreatedAt), 'lastKnownFeeConfigCreatedAt')
|
||
})
|
||
|
||
run()
|
||
console.log(
|
||
`[StateStore] Applied fee config @ event_created_at=${eventCreatedAt} ` +
|
||
`cash_in=${payload.cashInFeeFraction} cash_out=${payload.cashOutFeeFraction} ` +
|
||
`schema=${payload.schemaVersion}`
|
||
)
|
||
return { applied: true }
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Cassettes
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface CassetteRow {
|
||
denomination: number
|
||
count: number
|
||
position: number
|
||
}
|
||
|
||
export function loadCassettes(): CassetteRow[] {
|
||
if (!db) throw new Error('Database not initialized')
|
||
return db
|
||
.prepare('SELECT denomination, count, position FROM cassettes ORDER BY position, denomination')
|
||
.all() as CassetteRow[]
|
||
}
|
||
|
||
/**
|
||
* Upsert cassette rows. Position is the addressable unit; the same
|
||
* denomination may legitimately appear on multiple positions (real
|
||
* machines load N cassettes of the same denomination for cash-out
|
||
* throughput).
|
||
*/
|
||
export function setCassettes(
|
||
cassettes: { denomination: number; count: number; position?: number }[]
|
||
): void {
|
||
if (!db) throw new Error('Database not initialized')
|
||
|
||
const upsert = db.prepare(
|
||
'INSERT INTO cassettes (position, denomination, count) VALUES (?, ?, ?) ON CONFLICT(position) DO UPDATE SET denomination = excluded.denomination, count = excluded.count'
|
||
)
|
||
|
||
const run = db.transaction(
|
||
(rows: { denomination: number; count: number; position?: number }[]) => {
|
||
for (let i = 0; i < rows.length; i++) {
|
||
const row = rows[i]!
|
||
upsert.run(row.position ?? i + 1, row.denomination, row.count)
|
||
}
|
||
bumpCassetteStateSeq()
|
||
}
|
||
)
|
||
|
||
run(cassettes)
|
||
console.log('[StateStore] Cassettes updated:', cassettes)
|
||
}
|
||
|
||
/**
|
||
* Decrement the count for a specific physical bay (position). Use this
|
||
* after a successful dispense — the dispenser returns per-position
|
||
* results, so the caller already knows which bay drained how many.
|
||
*/
|
||
export function updateCassetteCountByPosition(position: number, delta: number): void {
|
||
if (!db) throw new Error('Database not initialized')
|
||
const database = db
|
||
|
||
database.transaction(() => {
|
||
database
|
||
.prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?')
|
||
.run(delta, position)
|
||
bumpCassetteStateSeq()
|
||
})()
|
||
}
|
||
|
||
/**
|
||
* Get aggregate inventory as denomination → total-count-across-positions.
|
||
* Used by the renderer / state machine for "do we have enough $20s to
|
||
* cover this withdraw" gating where the per-bay breakdown doesn't matter.
|
||
* For per-bay state use `loadCassettes()`.
|
||
*/
|
||
export function getInventory(): Record<number, number> {
|
||
const rows = loadCassettes()
|
||
const inv: Record<number, number> = {}
|
||
for (const row of rows) {
|
||
// Zero-count bays are KEPT. Dropping them made a drained machine
|
||
// indistinguishable from an unconfigured one, and every caller reads an
|
||
// empty map as "I don't know, ask the hardware" — so the last non-empty
|
||
// snapshot stuck and the availability beacon went on advertising bills
|
||
// that had already been dispensed. An empty map now means exactly one
|
||
// thing: no cassettes are configured. Consumers already filter for
|
||
// `> 0` before offering a denomination (CashOutView, machine.ts).
|
||
inv[row.denomination] = (inv[row.denomination] ?? 0) + row.count
|
||
}
|
||
return inv
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Cashbox
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface CashboxRow {
|
||
totalBills: number
|
||
totalFiatCents: number
|
||
lastEmptiedAt: number | null
|
||
}
|
||
|
||
export function getCashbox(): CashboxRow {
|
||
if (!db) throw new Error('Database not initialized')
|
||
|
||
const row = db
|
||
.prepare('SELECT total_bills, total_fiat_cents, last_emptied_at FROM cashbox WHERE id = 1')
|
||
.get() as { total_bills: number; total_fiat_cents: number; last_emptied_at: number | null }
|
||
|
||
return {
|
||
totalBills: row.total_bills,
|
||
totalFiatCents: row.total_fiat_cents,
|
||
lastEmptiedAt: row.last_emptied_at,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Increment cashbox totals after a cash-in transaction.
|
||
*/
|
||
export function addToCashbox(bills: number, fiatCents: number): void {
|
||
if (!db) throw new Error('Database not initialized')
|
||
|
||
db.prepare(
|
||
'UPDATE cashbox SET total_bills = total_bills + ?, total_fiat_cents = total_fiat_cents + ? WHERE id = 1'
|
||
).run(bills, fiatCents)
|
||
}
|
||
|
||
/**
|
||
* Reset the cashbox (operator emptied it).
|
||
*/
|
||
export function emptyCashbox(): void {
|
||
if (!db) throw new Error('Database not initialized')
|
||
|
||
db.prepare(
|
||
'UPDATE cashbox SET total_bills = 0, total_fiat_cents = 0, last_emptied_at = ? WHERE id = 1'
|
||
).run(Date.now())
|
||
|
||
console.log('[StateStore] Cashbox emptied')
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Transactions
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface TransactionInput {
|
||
txid: string
|
||
type: 'cash_in' | 'cash_out' | 'manual_dispense'
|
||
status: 'complete' | 'dispense_error' | 'partial' | 'remediated'
|
||
fiatCents: number
|
||
sats: number
|
||
feeSats: number
|
||
feeFraction: number
|
||
exchangeRate: number
|
||
currency: string
|
||
bills: { denomination: number; count: number }[]
|
||
cassettes?: {
|
||
name: string
|
||
position: number
|
||
denomination: number
|
||
provisioned: number
|
||
dispensed: number
|
||
rejected: number
|
||
}[]
|
||
error?: string | null
|
||
/**
|
||
* ADR-005 §2: dispense outcome to queue for spirekeeper. Inserted in the
|
||
* same transaction as the row so a crash between them cannot lose it.
|
||
*/
|
||
report?: DispenseReportBody
|
||
}
|
||
|
||
/**
|
||
* Record a completed transaction and update cassette/cashbox state atomically.
|
||
*/
|
||
export function recordTransaction(tx: TransactionInput): void {
|
||
if (!db) throw new Error('Database not initialized')
|
||
|
||
if (tx.feeFraction < 0 || tx.feeFraction > 1) {
|
||
throw new Error(
|
||
`[StateStore] feeFraction out of range [0, 1]: ${tx.feeFraction}. ` +
|
||
`Unit fraction expected (0.05 = 5%), not a percentage.`
|
||
)
|
||
}
|
||
|
||
const insertTx = db.prepare(
|
||
'INSERT INTO transactions (txid, type, status, error, fiat_cents, sats, fee_sats, fee_fraction, exchange_rate, currency, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||
)
|
||
const insertBill = db.prepare(
|
||
'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)'
|
||
)
|
||
// Outbox row (ADR-005 §2). REPLACE: a re-record of the same txid (should not
|
||
// happen, but a crash-replay could) refreshes the payload and resets the
|
||
// delivery state rather than failing the whole transaction.
|
||
const insertReport = db.prepare(
|
||
'INSERT OR REPLACE INTO dispense_reports (txid, payload, created_at, attempts, last_attempt_at, last_error, acked_at) VALUES (?, ?, ?, 0, NULL, NULL, NULL)'
|
||
)
|
||
const insertCassetteBill = db.prepare(
|
||
'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||
)
|
||
const updateCassetteByPosition = db.prepare(
|
||
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?'
|
||
)
|
||
const selectBaysByDenom = db.prepare(
|
||
'SELECT position, count FROM cassettes WHERE denomination = ? ORDER BY position'
|
||
)
|
||
const updateCashboxStmt = db.prepare(
|
||
'UPDATE cashbox SET total_bills = total_bills + ?, total_fiat_cents = total_fiat_cents + ? WHERE id = 1'
|
||
)
|
||
|
||
const run = db.transaction((t: TransactionInput) => {
|
||
insertTx.run(
|
||
t.txid,
|
||
t.type,
|
||
t.status,
|
||
t.error ?? null,
|
||
t.fiatCents,
|
||
t.sats,
|
||
t.feeSats,
|
||
t.feeFraction,
|
||
t.exchangeRate,
|
||
t.currency,
|
||
Date.now()
|
||
)
|
||
|
||
for (const bill of t.bills) {
|
||
insertBill.run(t.txid, bill.denomination, bill.count)
|
||
}
|
||
|
||
if (t.report) {
|
||
insertReport.run(t.txid, JSON.stringify(t.report), Date.now())
|
||
}
|
||
|
||
// Insert per-cassette detail when available
|
||
if (t.cassettes) {
|
||
for (const c of t.cassettes) {
|
||
insertCassetteBill.run(
|
||
t.txid,
|
||
c.name,
|
||
c.position,
|
||
c.denomination,
|
||
c.provisioned,
|
||
c.dispensed,
|
||
c.rejected
|
||
)
|
||
}
|
||
}
|
||
|
||
// Any dispense empties bays, whoever asked for it. `manual_dispense`
|
||
// (operator remediation, via the command poller or a kind-21003 command)
|
||
// used to fall outside this branch: HAL decremented its in-memory bays but
|
||
// the rows here did not move, and on the next boot HAL re-seeds from these
|
||
// rows — so the machine came back believing it still held bills a customer
|
||
// had already been handed (#76). A remediation against a partly-dispensed
|
||
// original decrements again on purpose: the original only ever debited what
|
||
// physically left, and this is a second lot of bills leaving.
|
||
if (t.type === 'cash_out' || t.type === 'manual_dispense') {
|
||
// Decrement cassettes by ACTUALLY dispensed count (not requested).
|
||
// Position is the addressable unit (v9): duplicate denominations
|
||
// across bays are legal, so a denomination-keyed UPDATE would
|
||
// decrement every matching bay.
|
||
if (t.cassettes) {
|
||
for (const c of t.cassettes) {
|
||
if (c.dispensed > 0) {
|
||
updateCassetteByPosition.run(-c.dispensed, c.position)
|
||
}
|
||
}
|
||
} else {
|
||
// Fallback: per-denomination bill counts (mocks without per-bay
|
||
// results). Drain matching bays greedily in position order —
|
||
// the dispenser's own fill order.
|
||
for (const bill of t.bills) {
|
||
let remaining = bill.count
|
||
const bays = selectBaysByDenom.all(bill.denomination) as {
|
||
position: number
|
||
count: number
|
||
}[]
|
||
for (const bay of bays) {
|
||
if (remaining <= 0) break
|
||
const take = Math.min(remaining, bay.count)
|
||
if (take <= 0) continue
|
||
updateCassetteByPosition.run(-take, bay.position)
|
||
remaining -= take
|
||
}
|
||
}
|
||
}
|
||
// The counts moved, so the sequence must move with them, inside this
|
||
// same transaction. It rides in the state document as the operator's
|
||
// way to reject a regression without trusting either clock.
|
||
bumpCassetteStateSeq()
|
||
}
|
||
|
||
if (t.type === 'cash_in') {
|
||
// Bills inserted by customer go into cashbox
|
||
const totalBills = t.bills.reduce((sum, b) => sum + b.count, 0)
|
||
updateCashboxStmt.run(totalBills, t.fiatCents)
|
||
}
|
||
})
|
||
|
||
run(tx)
|
||
console.log('[StateStore] Recorded transaction:', tx.txid, tx.type, `(${tx.status})`)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Operator Command Queue
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface OperatorCommand {
|
||
id: number
|
||
command: string
|
||
status: string
|
||
created_at: number
|
||
}
|
||
|
||
/**
|
||
* Get the next pending operator command (FIFO).
|
||
*/
|
||
export function getPendingCommand(): OperatorCommand | null {
|
||
if (!db) throw new Error('Database not initialized')
|
||
|
||
const row = db
|
||
.prepare(
|
||
"SELECT id, command, status, created_at FROM operator_commands WHERE status = 'pending' ORDER BY id LIMIT 1"
|
||
)
|
||
.get() as { id: number; command: string; status: string; created_at: number } | undefined
|
||
|
||
return row ?? null
|
||
}
|
||
|
||
/**
|
||
* Mark a command as executing (prevents double-pickup).
|
||
*/
|
||
export function markCommandExecuting(id: number): void {
|
||
if (!db) throw new Error('Database not initialized')
|
||
db.prepare(
|
||
"UPDATE operator_commands SET status = 'executing' WHERE id = ? AND status = 'pending'"
|
||
).run(id)
|
||
}
|
||
|
||
/**
|
||
* Complete a command with a result.
|
||
*/
|
||
export function completeCommand(id: number, result: string, error?: boolean): void {
|
||
if (!db) throw new Error('Database not initialized')
|
||
db.prepare(
|
||
'UPDATE operator_commands SET status = ?, result = ?, completed_at = ? WHERE id = ?'
|
||
).run(error ? 'error' : 'complete', result, Date.now(), id)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Transaction Remediation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Mark a failed transaction as remediated by a manual dispense.
|
||
* Only updates transactions with status 'dispense_error' or 'partial'.
|
||
* Returns true if the transaction was updated, false if not found or not in error state.
|
||
*/
|
||
export function remediateTransaction(txid: string, remediatedByTxid: string): boolean {
|
||
if (!db) throw new Error('Database not initialized')
|
||
|
||
const result = db
|
||
.prepare(
|
||
'UPDATE transactions SET status = ?, remediated_by = ? WHERE txid = ? AND status IN (?, ?)'
|
||
)
|
||
.run('remediated', remediatedByTxid, txid, 'dispense_error', 'partial')
|
||
|
||
if (result.changes > 0) {
|
||
console.log('[StateStore] Remediated transaction:', txid, '→', remediatedByTxid)
|
||
}
|
||
return result.changes > 0
|
||
}
|
||
|
||
/**
|
||
* Close the database (for clean shutdown).
|
||
*/
|
||
export function closeDatabase(): void {
|
||
if (db) {
|
||
db.close()
|
||
db = null
|
||
console.log('[StateStore] Database closed')
|
||
}
|
||
}
|