feat(machine): v1.1 cassette config — position-keyed wire, multi-same-denom HAL
Mirrors satmachineadmin's PR #30 v1.1 commits (df6e8e0..1cebefc). Three load-bearing corrections from the v1.0 implementation: 1. **Wire shape flips from denomination-keyed to position-keyed** (`{positions: {<pos>: {denomination, count}}}`). The original `#56` spec was position-keyed; my `06:40Z` audit-and-flip was wrong on both the load-bearingness of the ATM denom-PK invariant AND on the operational requirement (per-slot denomination must be operator- editable for swap-during-refill). 2. **Drop "one cassette per denomination" invariant.** Real production machines load multiple cassettes with the same denomination for cash-out throughput on a single bill class (4 × $20 cassettes on Tejo/batm3 are normal). NO unique index on denomination. 3. **HAL refactor for per-position state + greedy distribution.** When asked for N of denomination D, iterate matching bays in position order draining greedy until the request is satisfied or all matching bays empty. Surfaces "Insufficient inventory for denomination D: short K" rather than crashing on the first under-stocked bay. Schema migration v8 → v9: rebuild `cassettes` with `position INTEGER PRIMARY KEY`, `denomination INTEGER NOT NULL`, `count INTEGER NOT NULL DEFAULT 0`. SQLite create-copy-drop-rename per the v4→v5 precedent (FKs off during, no data loss). Existing rows backfill column-by-column. `setCassettes()` upserts `ON CONFLICT(position)`. `updateCassetteCount (denomination, delta)` → `updateCassetteCountByPosition(position, delta)` since the dispenser returns per-position results. `getInventory()` boundary stays denomination-keyed (sums across matching bays) for backwards compat with renderer callers. HAL `inventory: Record<denom, count>` + `cassetteDenominations: number[]` collapse into a single `bays: {position, denomination, count}[]` array. Dispense per-bay note assignment + per-bay decrement on result. Bay ordering by position throughout. Operator-config consumer (`operator-config.ts`) flips both the apply direction (`{positions: ...}` parse + validate position-set equality + denom/count int checks, NO denom-uniqueness) and the bootstrap publish direction (position-keyed payload encoding). IPC type signatures updated in `preload.ts` + `types/electron.d.ts` for both the new `OperatorCassettesPayload` shape and the per-position `halReloadCassettes` argument. `atm-tui` schema flip + handler updates land in a separate commit on `aiolabs/atm-tui` (this commit's changes are limited to lamassu-next). Bumping the atm-tui flake input on `deploy/server-deploy` (or the local flake.lock here) after the atm-tui push reaches the sintra closure. 12/12 typecheck, 18/18 state-machine tests, 11/11 clink, 11/11 lnbits, 11/11 nostr-client all green. Design history: `~/dev/coordination/log.md` entries 2026-05-30T06:30Z → 20:55Z. Satmachineadmin counterpart at PR #30. Issue body refreshed. refs: aiolabs/lamassu-next#56, aiolabs/satmachineadmin#29, aiolabs/satmachineadmin PR #30 (commits df6e8e0..1cebefc) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4612ff2155
commit
41f9412524
6 changed files with 232 additions and 107 deletions
|
|
@ -15,7 +15,7 @@ import fs from 'node:fs'
|
|||
|
||||
let db: Database.Database | null = null
|
||||
|
||||
const SCHEMA_VERSION = '8'
|
||||
const SCHEMA_VERSION = '9'
|
||||
|
||||
function getDbPath(): string {
|
||||
const prodDir = '/var/lib/bitspire'
|
||||
|
|
@ -52,9 +52,9 @@ export function initDatabase(dbPath?: string): void {
|
|||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cassettes (
|
||||
denomination INTEGER PRIMARY KEY,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
position INTEGER NOT NULL DEFAULT 0
|
||||
position INTEGER PRIMARY KEY,
|
||||
denomination INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cashbox (
|
||||
|
|
@ -243,6 +243,39 @@ export function initDatabase(dbPath?: string): void {
|
|||
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)')
|
||||
}
|
||||
|
||||
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
|
||||
|
|
@ -304,7 +337,7 @@ export function markBootstrapPublished(unixTimestamp: number): void {
|
|||
}
|
||||
|
||||
export type OperatorCassettesPayload = {
|
||||
denominations: Record<string, { position: number; count: number }>
|
||||
positions: Record<string, { denomination: number; count: number }>
|
||||
}
|
||||
|
||||
export type ApplyResult =
|
||||
|
|
@ -319,13 +352,17 @@ export type ApplyResult =
|
|||
*
|
||||
* 1. Rechecks replay-protection against `meta.lastKnownConfigCreatedAt`
|
||||
* (defense-in-depth — caller should have done this too).
|
||||
* 2. Validates the payload's `denominations` key set is *exactly* the set
|
||||
* of denominations currently in the `cassettes` table.
|
||||
* 3. Validates per-entry `position` is a positive int, `count` is a
|
||||
* non-negative int.
|
||||
* 4. In a single SQLite transaction: updates `cassettes` rows (PK is
|
||||
* denomination — only `position` and `count` mutate) AND advances the
|
||||
* `meta.lastKnownConfigCreatedAt` watermark to `eventCreatedAt`.
|
||||
* 2. Validates the payload's `positions` key set is *exactly* the set of
|
||||
* positions currently in the `cassettes` table. The bay count is
|
||||
* hardware-determined and can't be added to or removed from via this
|
||||
* path; only the per-bay denomination and count are operator-mutable.
|
||||
* 3. Validates per-entry `denomination` is a positive int, `count` is a
|
||||
* non-negative int. **Duplicate denominations across positions are
|
||||
* intentionally permitted** — real machines load multiple cassettes
|
||||
* with the same denomination for cash-out throughput.
|
||||
* 4. In a single SQLite transaction: updates `cassettes` rows by position
|
||||
* (denomination + count both mutable per row) AND advances
|
||||
* `meta.lastKnownConfigCreatedAt` to `eventCreatedAt`.
|
||||
*
|
||||
* Mid-write crashes roll back cleanly; on restart the same event is
|
||||
* re-delivered by the relay and the watermark check drops it as already
|
||||
|
|
@ -347,58 +384,58 @@ export function applyOperatorCassettesConfig(
|
|||
}
|
||||
|
||||
const currentRows = db
|
||||
.prepare('SELECT denomination FROM cassettes')
|
||||
.all() as { denomination: number }[]
|
||||
const currentDenoms = new Set(currentRows.map((r) => r.denomination))
|
||||
const payloadDenoms = new Set(Object.keys(payload.denominations).map((k) => Number(k)))
|
||||
.prepare('SELECT position FROM cassettes')
|
||||
.all() as { position: number }[]
|
||||
const currentPositions = new Set(currentRows.map((r) => r.position))
|
||||
const payloadPositions = new Set(Object.keys(payload.positions).map((k) => Number(k)))
|
||||
|
||||
if (currentDenoms.size !== payloadDenoms.size) {
|
||||
if (currentPositions.size !== payloadPositions.size) {
|
||||
return {
|
||||
applied: false,
|
||||
reason: `denomination count mismatch: state.db has ${currentDenoms.size}, payload has ${payloadDenoms.size}`,
|
||||
reason: `position count mismatch: state.db has ${currentPositions.size}, payload has ${payloadPositions.size}`,
|
||||
}
|
||||
}
|
||||
for (const d of currentDenoms) {
|
||||
if (!payloadDenoms.has(d)) {
|
||||
return { applied: false, reason: `payload missing denomination ${d}` }
|
||||
for (const p of currentPositions) {
|
||||
if (!payloadPositions.has(p)) {
|
||||
return { applied: false, reason: `payload missing position ${p}` }
|
||||
}
|
||||
}
|
||||
for (const d of payloadDenoms) {
|
||||
if (!currentDenoms.has(d)) {
|
||||
return { applied: false, reason: `payload includes unknown denomination ${d}` }
|
||||
for (const p of payloadPositions) {
|
||||
if (!currentPositions.has(p)) {
|
||||
return { applied: false, reason: `payload includes unknown position ${p}` }
|
||||
}
|
||||
}
|
||||
|
||||
for (const [denomKey, entry] of Object.entries(payload.denominations)) {
|
||||
if (!Number.isInteger(entry.position) || entry.position <= 0) {
|
||||
for (const [posKey, entry] of Object.entries(payload.positions)) {
|
||||
if (!Number.isInteger(entry.denomination) || entry.denomination <= 0) {
|
||||
return {
|
||||
applied: false,
|
||||
reason: `position must be positive int (denomination ${denomKey}, got ${entry.position})`,
|
||||
reason: `denomination must be positive int (position ${posKey}, got ${entry.denomination})`,
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(entry.count) || entry.count < 0) {
|
||||
return {
|
||||
applied: false,
|
||||
reason: `count must be non-negative int (denomination ${denomKey}, got ${entry.count})`,
|
||||
reason: `count must be non-negative int (position ${posKey}, got ${entry.count})`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateCassette = db.prepare(
|
||||
'UPDATE cassettes SET position = ?, count = ? WHERE denomination = ?'
|
||||
'UPDATE cassettes SET denomination = ?, count = ? WHERE position = ?'
|
||||
)
|
||||
const setWatermark = db.prepare('UPDATE meta SET value = ? WHERE key = ?')
|
||||
|
||||
const run = db.transaction(() => {
|
||||
for (const [denomKey, entry] of Object.entries(payload.denominations)) {
|
||||
updateCassette.run(entry.position, entry.count, Number(denomKey))
|
||||
for (const [posKey, entry] of Object.entries(payload.positions)) {
|
||||
updateCassette.run(entry.denomination, entry.count, Number(posKey))
|
||||
}
|
||||
setWatermark.run(String(eventCreatedAt), 'lastKnownConfigCreatedAt')
|
||||
})
|
||||
|
||||
run()
|
||||
console.log(
|
||||
`[StateStore] Applied operator cassettes config @ created_at=${eventCreatedAt} (${Object.keys(payload.denominations).length} denominations)`
|
||||
`[StateStore] Applied operator cassettes config @ created_at=${eventCreatedAt} (${Object.keys(payload.positions).length} positions)`
|
||||
)
|
||||
return { applied: true }
|
||||
}
|
||||
|
|
@ -421,7 +458,10 @@ export function loadCassettes(): CassetteRow[] {
|
|||
}
|
||||
|
||||
/**
|
||||
* Upsert cassette counts. Replaces all existing rows.
|
||||
* 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 }[]
|
||||
|
|
@ -429,14 +469,14 @@ export function setCassettes(
|
|||
if (!db) throw new Error('Database not initialized')
|
||||
|
||||
const upsert = db.prepare(
|
||||
'INSERT INTO cassettes (denomination, count, position) VALUES (?, ?, ?) ON CONFLICT(denomination) DO UPDATE SET count = excluded.count, position = excluded.position'
|
||||
'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.denomination, row.count, row.position ?? i + 1)
|
||||
upsert.run(row.position ?? i + 1, row.denomination, row.count)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -446,26 +486,31 @@ export function setCassettes(
|
|||
}
|
||||
|
||||
/**
|
||||
* Increment or decrement a cassette count (e.g., after dispensing).
|
||||
* 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 updateCassetteCount(denomination: number, delta: number): void {
|
||||
export function updateCassetteCountByPosition(position: number, delta: number): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
|
||||
db.prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?').run(
|
||||
db.prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?').run(
|
||||
delta,
|
||||
denomination
|
||||
position
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inventory as a denomination -> count map.
|
||||
* 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) {
|
||||
if (row.count > 0) {
|
||||
inv[row.denomination] = row.count
|
||||
inv[row.denomination] = (inv[row.denomination] ?? 0) + row.count
|
||||
}
|
||||
}
|
||||
return inv
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue