feat(machine): add SQLite persistence and remove npub linking
Add crash-safe persistence for cassette inventory, cashbox state, and transaction history using better-sqlite3 in the Electron main process. The state machine now loads inventory from the database at runtime instead of using hardcoded values, and transactions are automatically persisted on completion. Remove the unnecessary npub linking code — Lightning.Pub auto-creates and associates Nostr users when appId is included in RPC requests, making the HTTP-based user creation and token linking redundant. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
722635a5a6
commit
f37555565e
15 changed files with 739 additions and 171 deletions
271
apps/machine/electron/state-store.ts
Normal file
271
apps/machine/electron/state-store.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
/**
|
||||
* 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/lamassu-atm/state.db (production)
|
||||
* ./state.db (development)
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
|
||||
let db: Database.Database | null = null
|
||||
|
||||
const SCHEMA_VERSION = '1'
|
||||
|
||||
function getDbPath(): string {
|
||||
const prodDir = '/var/lib/lamassu-atm'
|
||||
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 (
|
||||
denomination INTEGER PRIMARY KEY,
|
||||
count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
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')),
|
||||
fiat_cents INTEGER NOT NULL,
|
||||
sats INTEGER NOT NULL,
|
||||
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
|
||||
);
|
||||
`)
|
||||
|
||||
// Seed meta + cashbox if first run
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cassettes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CassetteRow {
|
||||
denomination: number
|
||||
count: number
|
||||
}
|
||||
|
||||
export function loadCassettes(): CassetteRow[] {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
return db
|
||||
.prepare('SELECT denomination, count FROM cassettes ORDER BY denomination')
|
||||
.all() as CassetteRow[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert cassette counts. Replaces all existing rows.
|
||||
*/
|
||||
export function setCassettes(cassettes: CassetteRow[]): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
|
||||
const upsert = db.prepare(
|
||||
'INSERT INTO cassettes (denomination, count) VALUES (?, ?) ON CONFLICT(denomination) DO UPDATE SET count = excluded.count'
|
||||
)
|
||||
|
||||
const run = db.transaction((rows: CassetteRow[]) => {
|
||||
for (const row of rows) {
|
||||
upsert.run(row.denomination, row.count)
|
||||
}
|
||||
})
|
||||
|
||||
run(cassettes)
|
||||
console.log('[StateStore] Cassettes updated:', cassettes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment or decrement a cassette count (e.g., after dispensing).
|
||||
*/
|
||||
export function updateCassetteCount(denomination: number, delta: number): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
|
||||
db.prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?').run(
|
||||
delta,
|
||||
denomination
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inventory as a denomination -> count map.
|
||||
*/
|
||||
export function getInventory(): Record<number, number> {
|
||||
const rows = loadCassettes()
|
||||
const inv: Record<number, number> = {}
|
||||
for (const row of rows) {
|
||||
if (row.count > 0) {
|
||||
inv[row.denomination] = 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'
|
||||
fiatCents: number
|
||||
sats: number
|
||||
bills: { denomination: number; count: number }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a completed transaction and update cassette/cashbox state atomically.
|
||||
*/
|
||||
export function recordTransaction(tx: TransactionInput): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
|
||||
const insertTx = db.prepare(
|
||||
'INSERT INTO transactions (txid, type, fiat_cents, sats, created_at) VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
const insertBill = db.prepare(
|
||||
'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)'
|
||||
)
|
||||
const updateCassette = db.prepare(
|
||||
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?'
|
||||
)
|
||||
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.fiatCents, t.sats, Date.now())
|
||||
|
||||
for (const bill of t.bills) {
|
||||
insertBill.run(t.txid, bill.denomination, bill.count)
|
||||
|
||||
if (t.type === 'cash_out') {
|
||||
// Dispensed bills: decrement cassettes
|
||||
updateCassette.run(-bill.count, bill.denomination)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database (for clean shutdown).
|
||||
*/
|
||||
export function closeDatabase(): void {
|
||||
if (db) {
|
||||
db.close()
|
||||
db = null
|
||||
console.log('[StateStore] Database closed')
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue