feat(v2): m008 flip cassette_configs PK to (machine_id, position) (#29 v1.1)

Coordinated v1.1 fix with the ATM side per coord-log 2026-05-30T18:30Z
+ 18:45Z. The m007 schema's denomination-PK was wrong:

  - Operators need to swap cartridge denominations during refill (a $20
    bay becomes a $50 bay) without re-provisioning. m007 made
    denomination immutable per slot.
  - Real machines have N cartridges of the same denomination for
    cash-out throughput (e.g., four $20 cartridges on a single ATM to
    avoid mid-day refill). m007 + denomination-PK rejected duplicates.

The flip:
  - PK becomes (machine_id, position). Position is the fixed hardware
    bay number; denomination + count are operator-editable per row.
  - No UNIQUE constraint on denomination — multiple same-denom cassettes
    are operationally valid.
  - New nullable column state_denomination for v2 reverse-channel
    reconciliation (operator-believed denomination per slot vs ATM-
    reported denomination — diff highlighting in v2 UI).

SQLite doesn't support ALTER PRIMARY KEY directly; the migration does
the standard create-new / backfill-from-old / drop / rename dance.
Idempotent via state_denomination column-probe.

Backfill choice: in m007 the row's denomination was simultaneously the
operator-believed AND the ATM-reported value (only write path was the
bootstrap consumer copying state.db verbatim). At migration time
state_denomination = current denomination as a best-guess baseline;
the next bootstrap event re-populates the state_* columns
authoritatively per the v1.1 wire shape.

Wire shape, models, CRUD, transport, consumer, API, and UI all flip in
subsequent commits. PR #30 will grow from 9 → 14 commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-30 22:22:29 +02:00
commit df6e8e0a22

View file

@ -575,3 +575,80 @@ async def m007_add_cassette_configs(db):
PRIMARY KEY (machine_id, denomination) PRIMARY KEY (machine_id, denomination)
); );
""") """)
async def m008_flip_cassette_configs_pk_to_position(db):
"""Flip cassette_configs PK from (machine_id, denomination) to
(machine_id, position). The denomination-keyed shape from m007 was
wrong: real machines have N cartridges of the same denomination
(cash-out throughput requires multiple bays for one denom), and the
operator needs to swap cartridge denominations during refill ($20
bay becomes $50 bay) without a re-provisioning event.
Coordinated v1.1 fix with the ATM side per the 2026-05-30T18:30Z +
18:45Z log entries:
- Wire shape flips from {denominations: {<d>: {position, count}}}
to {positions: {<p>: {denomination, count}}}
- Position becomes the fixed row identity (hardware bay number);
denomination + count are operator-editable per row
- NO unique constraint on denomination (multiple same-denom cassettes
are operationally valid)
Also adds `state_denomination` nullable column reserved for v2
reverse-channel reconciliation (operator-believed denomination per
slot vs ATM-reported denomination diff highlighting in v2 UI).
SQLite doesn't support ALTER PRIMARY KEY directly; the migration
does the standard create-copy-drop-rename dance. Idempotent via the
column-probe trick used elsewhere in this file.
"""
try:
# Probe: does the old PK shape still exist? If state_denomination
# column already exists, m008 already ran — no-op.
await db.fetchone(
"SELECT state_denomination FROM satoshimachine.cassette_configs "
"LIMIT 1"
)
return
except Exception:
pass
await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.cassette_configs_new (
machine_id TEXT NOT NULL,
position INTEGER NOT NULL,
denomination INTEGER NOT NULL,
count INTEGER NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
updated_by TEXT,
state_denomination INTEGER,
state_count INTEGER,
state_at TIMESTAMP,
state_event_id TEXT,
PRIMARY KEY (machine_id, position)
);
""")
# Backfill from the old table — column-by-column copy. In the v1
# m007 schema the row's `denomination` was simultaneously the
# operator-believed denomination AND the ATM-reported denomination
# (because the only write path was the bootstrap consumer copying
# from the ATM's state.db). So state_denomination at migration time
# = current denomination as a best-guess baseline; the next bootstrap
# event re-populates the state_* columns authoritatively.
await db.execute("""
INSERT INTO satoshimachine.cassette_configs_new
(machine_id, position, denomination, count,
updated_at, updated_by,
state_denomination, state_count, state_at, state_event_id)
SELECT machine_id, position, denomination, count,
updated_at, updated_by,
denomination, state_count, state_at, state_event_id
FROM satoshimachine.cassette_configs
""")
await db.execute("DROP TABLE satoshimachine.cassette_configs")
await db.execute(
"ALTER TABLE satoshimachine.cassette_configs_new "
"RENAME TO cassette_configs"
)