euro: money that moves over a retrying wire needs a name

Every caller of Credit/Debit today is a Matrix message, and a Matrix message
arrives once. The Pete games escrow will claim buy-ins over a poll loop, where
a claim that succeeds but whose ack is lost gets retried — and the player pays
twice. euro_transactions had no external id and no unique constraint to stop it.

Adds external_id + a partial unique index, and CreditIdem/DebitIdem: balance
mutation and transaction log in one tx, keyed by the escrow GUID. A replay is a
no-op that still reports ok; a rejection leaves no trace, so the same GUID can
be retried once the player is good for it.
This commit is contained in:
prosolis
2026-07-13 22:36:21 -07:00
parent 65a48b4bd7
commit ab2bcf0c59
3 changed files with 319 additions and 0 deletions

View File

@@ -452,6 +452,11 @@ func runMigrations(d *sql.DB) error {
// the buyer when the target survives (the unseal), so piling on carries the
// same exposure the original purchase does.
`ALTER TABLE mischief_contracts ADD COLUMN escalated_by TEXT`,
// Pete games — a caller-supplied idempotency key for money moves that
// arrive over a retrying wire rather than a Matrix message. A Matrix
// message arrives once; a poll loop whose ack is lost on the wire will
// retry, and without this the player pays twice. See euro.DebitIdem.
`ALTER TABLE euro_transactions ADD COLUMN external_id TEXT`,
}
for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil {
@@ -468,6 +473,18 @@ func runMigrations(d *sql.DB) error {
return fmt.Errorf("migration %q: %w", stmt, err)
}
}
// Indexes over columns the column migrations above just added. These can't
// live in the schema block, which runs before those columns exist.
indexMigrations := []string{
`CREATE UNIQUE INDEX IF NOT EXISTS idx_euro_tx_external
ON euro_transactions(external_id) WHERE external_id IS NOT NULL`,
}
for _, stmt := range indexMigrations {
if _, err := d.Exec(stmt); err != nil {
return fmt.Errorf("index migration %q: %w", stmt, err)
}
}
return nil
}