From df6e8e0a22ee5ffab492ba206273471d6734a855 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 30 May 2026 22:22:29 +0200 Subject: [PATCH] feat(v2): m008 flip cassette_configs PK to (machine_id, position) (#29 v1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- migrations.py | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/migrations.py b/migrations.py index 807292f..5489f96 100644 --- a/migrations.py +++ b/migrations.py @@ -575,3 +575,80 @@ async def m007_add_cassette_configs(db): 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: {: {position, count}}} + to {positions: {

: {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" + )