feat(cassettes): consume operator operations instead of counts
The machine now owns its bay counts outright. The operator publishes what it did — a refill in notes added, an empty, a recount, a denomination change — and this process applies it to the total it already holds. Both sides used to write the same value over a transport that never tells a writer it lost. Addressable events order by created_at at second granularity with ties broken on event id, and a relay returns OK for an event it then discards, so a dashboard form loaded before a dispense silently discarded that dispense and neither side could detect it. A value with one writer cannot be clobbered. Schema v13 adds cassette_ops, 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 this table records the ones applied. That also retires the created_at watermark on this path: it was the only replay defence under absolute counts, but it drops an out-of-order event whole, operations included, where per-op ids let the unseen ones through and no-op the rest. A window is applied oldest-first by `at`, ties broken by id, in one transaction with the count mutation. A recount then a refill is not the same as the reverse, and a crash mid-apply must roll back to a coherent count rather than a partial one. A malformed op or one naming a bay this machine does not have is neither applied nor recorded, so it stays pending on the operator's dashboard. That is the honest outcome. Recording it as applied would stop the noise by telling the operator their refill landed. The state document gains applied_ops, seq and schema_version. applied_ops is the acknowledgement leg — echoing the ids back is the only way the operator can tell an operation that landed from one merely sent. seq is bumped on every local count change from any cause, so a reader can reject a regression without trusting either clock.
This commit is contained in:
parent
9077f9c299
commit
c889f7f0df
6 changed files with 537 additions and 165 deletions
|
|
@ -12,8 +12,10 @@
|
|||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyOperatorCassettesConfig,
|
||||
applyOperatorCassetteOps,
|
||||
closeDatabase,
|
||||
getAppliedOpIds,
|
||||
getCassetteStateSeq,
|
||||
getCashbox,
|
||||
getCountsUncertainSince,
|
||||
getInventory,
|
||||
|
|
@ -337,31 +339,157 @@ describe('state-store: unverified counts after a silent dispense', () => {
|
|||
expect(getCountsUncertainSince()).toBe(1000)
|
||||
})
|
||||
|
||||
it('clears when an operator asserts real counts', () => {
|
||||
it('clears on a recount, because that is what a recount is', () => {
|
||||
markCountsUncertain(1000)
|
||||
const applied = applyOperatorCassettesConfig(
|
||||
{
|
||||
positions: {
|
||||
'1': { denomination: 20, count: 40 },
|
||||
'2': { denomination: 20, count: 40 },
|
||||
'3': { denomination: 50, count: 25 },
|
||||
},
|
||||
},
|
||||
1_700_000_000
|
||||
)
|
||||
expect(applied.applied).toBe(true)
|
||||
// A recount is exactly the operator asserting authoritative counts.
|
||||
const result = applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'recount', position: 1, count: 40 },
|
||||
])
|
||||
expect(result.applied).toEqual(['op-1'])
|
||||
expect(getCountsUncertainSince()).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves the flag alone when the operator config is rejected', () => {
|
||||
it('does not clear on a refill', () => {
|
||||
// A refill adds to a number still known to be wrong. Only someone
|
||||
// opening the bay and counting it resolves that.
|
||||
markCountsUncertain(1000)
|
||||
// Position key-set mismatch — the bay layout is hardware-determined.
|
||||
const applied = applyOperatorCassettesConfig(
|
||||
{ positions: { '1': { denomination: 20, count: 40 } } },
|
||||
1_700_000_001
|
||||
)
|
||||
expect(applied.applied).toBe(false)
|
||||
const result = applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 },
|
||||
])
|
||||
expect(result.applied).toEqual(['op-1'])
|
||||
expect(getCountsUncertainSince()).toBe(1000)
|
||||
})
|
||||
|
||||
it('leaves the flag alone when the op is rejected', () => {
|
||||
markCountsUncertain(1000)
|
||||
// Bay 9 does not exist — the layout is hardware-determined.
|
||||
const result = applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'recount', position: 9, count: 40 },
|
||||
])
|
||||
expect(result.applied).toEqual([])
|
||||
expect(result.rejected).toHaveLength(1)
|
||||
expect(getCountsUncertainSince()).toBe(1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('state-store: operator cassette operations (ADR-004)', () => {
|
||||
beforeEach(() => {
|
||||
seedDuplicateDenomBays()
|
||||
})
|
||||
|
||||
it('applies a refill as a delta, not a total', () => {
|
||||
applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 30 },
|
||||
])
|
||||
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(80)
|
||||
})
|
||||
|
||||
it('is a no-op on a re-delivered operation', () => {
|
||||
// Addressable events are re-delivered on every relay reconnect and the
|
||||
// operator republishes a WINDOW, so the same op arrives many times. A
|
||||
// delta applied twice is simply wrong, which is why every op carries an
|
||||
// id and this table records the ones already applied.
|
||||
const op = {
|
||||
id: 'op-1',
|
||||
at: 1_700_000_000,
|
||||
type: 'refill' as const,
|
||||
position: 1,
|
||||
bills: 30,
|
||||
}
|
||||
expect(applyOperatorCassetteOps([op]).applied).toEqual(['op-1'])
|
||||
expect(applyOperatorCassetteOps([op]).applied).toEqual([])
|
||||
expect(applyOperatorCassetteOps([op, op]).applied).toEqual([])
|
||||
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(80)
|
||||
})
|
||||
|
||||
it('applies only the unseen ops from a window that mixes both', () => {
|
||||
applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 },
|
||||
])
|
||||
const result = applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 },
|
||||
{ id: 'op-2', at: 1_700_000_001, type: 'refill', position: 1, bills: 5 },
|
||||
])
|
||||
expect(result.applied).toEqual(['op-2'])
|
||||
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(65)
|
||||
})
|
||||
|
||||
it('applies a window oldest-first regardless of arrival order', () => {
|
||||
// A recount then a refill is not the same as the reverse, so ordering is
|
||||
// load-bearing and cannot be left to however the array arrived.
|
||||
applyOperatorCassetteOps([
|
||||
{ id: 'op-b', at: 1_700_000_002, type: 'refill', position: 1, bills: 7 },
|
||||
{ id: 'op-a', at: 1_700_000_001, type: 'recount', position: 1, count: 3 },
|
||||
])
|
||||
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(10)
|
||||
})
|
||||
|
||||
it('empties a bay and sets a denomination', () => {
|
||||
applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'empty', position: 2 },
|
||||
{ id: 'op-2', at: 1_700_000_001, type: 'set_denomination', position: 2, denomination: 10 },
|
||||
])
|
||||
const bay = loadCassettes().find((c) => c.position === 2)!
|
||||
expect(bay.count).toBe(0)
|
||||
expect(bay.denomination).toBe(10)
|
||||
})
|
||||
|
||||
it('rejects a malformed op without applying or recording it', () => {
|
||||
// Unrecorded on purpose: it stays pending on the operator's dashboard,
|
||||
// which is the honest outcome. Recording it as applied would silence the
|
||||
// noise by telling the operator their refill landed.
|
||||
const result = applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: -5 },
|
||||
])
|
||||
expect(result.applied).toEqual([])
|
||||
expect(result.rejected[0]!.id).toBe('op-1')
|
||||
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(50)
|
||||
expect(getAppliedOpIds()).not.toContain('op-1')
|
||||
})
|
||||
|
||||
it('echoes applied ids back, newest first', () => {
|
||||
applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 1 },
|
||||
{ id: 'op-2', at: 1_700_000_001, type: 'refill', position: 1, bills: 1 },
|
||||
])
|
||||
expect(getAppliedOpIds()).toContain('op-1')
|
||||
expect(getAppliedOpIds()).toContain('op-2')
|
||||
})
|
||||
|
||||
it('advances the sequence on an applied op but not on a duplicate', () => {
|
||||
const op = {
|
||||
id: 'op-1',
|
||||
at: 1_700_000_000,
|
||||
type: 'refill' as const,
|
||||
position: 1,
|
||||
bills: 1,
|
||||
}
|
||||
const before = getCassetteStateSeq()
|
||||
applyOperatorCassetteOps([op])
|
||||
const after = getCassetteStateSeq()
|
||||
expect(after).toBeGreaterThan(before)
|
||||
applyOperatorCassetteOps([op])
|
||||
expect(getCassetteStateSeq()).toBe(after)
|
||||
})
|
||||
|
||||
it('advances the sequence on a dispense', () => {
|
||||
const before = getCassetteStateSeq()
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-seq',
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 20, count: 1 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette1',
|
||||
position: 1,
|
||||
denomination: 20,
|
||||
provisioned: 1,
|
||||
dispensed: 1,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(getCassetteStateSeq()).toBeGreaterThan(before)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue