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