Files
plm-lottery/docs/modello-dati.md
T
davideandClaude Opus 5.5 61efbb0e37 Replace flowchart/ with a traceable specification in docs/
The two Mermaid diagrams under flowchart/ described the intended flow but
had drifted from the code and carried no link back to it. docs/ now holds
the specification instead: 15 Italian documents (flows, architecture, data
model, API, security, audit log, operations, decisions and limits, bug
history) with 472 numbered requirements, each pointing at the function
that implements it and the test that covers it. docs/processo-di-modifica.md
defines the ID convention and the spec -> code -> test change workflow, and
CLAUDE.md now names docs/ as authoritative.

Writing the spec from the code also corrected stale claims in CLAUDE.md
(B-41's get_history check, BUGS.md still tracking B-71/B-72, Cache-Control
scope, the error contract's bare-string exceptions) and in the operator
guides (payouts do retry, only `drawing` fails to resume, rounds do not
open without fee_address). The B-70 doc test now pins the prose in
docs/scommesse-e-prelievi.md instead of the deleted diagram.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:16:01 +02:00

61 KiB
Raw Blame History

Modello dati

Questo documento descrive il database di PLM Lottery: tutte le tabelle, ogni colonna, i valori ammessi per i campi di stato e le transizioni tra di essi, gli indici e i vincoli, le invarianti che legano più tabelle, la configurazione del motore SQLAlchemy e la politica delle migrazioni Alembic.

La definizione di riferimento è app/db/models.py; lo schema effettivo di un'istanza è quello prodotto dalla catena di migrazioni in migrations/versions/. Le due cose coincidono (vedi Migrazioni), con le piccole differenze segnalate lì.

Cosa non c'è qui, e dove trovarlo:

Indice

Diagramma delle relazioni

Nove tabelle applicative. round_config non ha relazioni: è una riga singola di parametri. L'indirizzo del montepremi (il "pool") non è in nessuna tabella: è derivato dalla master key (m/84'/746'/0'/1/0, app/wallet/hd.py:derive_pool_key) e i suoi UTXO si leggono dalla rete.

erDiagram
    users ||--o{ utxo_events : "possiede"
    users ||--o{ round_participants : "scommette"
    rounds ||--o{ round_participants : "raccoglie"
    users |o--o{ rounds : "vince (winner_user_id)"
    users ||--o{ withdrawals : "chiede"
    rounds |o--o{ pending_transactions : "bet / payout"
    withdrawals |o--o{ pending_transactions : "withdrawal"
    users |o--o{ pending_transactions : "bet / withdrawal"
    users |o--o{ bug_reports : "segnala"
    users |o--o{ audit_log : "riguarda"
    rounds |o--o{ audit_log : "riguarda"

    users {
        int id PK
        string username UK "unico anche su lower(username)"
        string password_hash "Argon2"
        int derivation_index UK
        string address UK "P2WPKH plm1..."
        bigint cached_balance_sats "cache di utxo_events"
        int token_version
        datetime created_at
    }
    utxo_events {
        int id PK
        int user_id FK
        string txid "UK con vout"
        int vout
        bigint amount_sats
        int confirmed_height
        datetime confirmed_at
        string spent_txid "NULL, txid o external-spend"
    }
    rounds {
        int id PK
        string status "open closing drawing paying_out closed"
        datetime opened_at
        datetime closed_at
        int duration_seconds "snapshot"
        int cooldown_seconds "snapshot"
        datetime drawing_started_at
        int draw_block_height
        string draw_block_hash
        string seed_int
        int winner_user_id FK
        bigint pool_amount_sats
        bigint winner_amount_sats
        bigint fee_amount_sats
        string payout_txid
    }
    round_participants {
        int id PK
        int round_id FK "UK con user_id"
        int user_id FK
        bigint bet_amount_sats
        string bet_txid
        datetime broadcast_at
        datetime confirmed_at
        string status "building broadcast confirmed"
    }
    round_config {
        int id PK
        string fee_address
        bigint bet_amount_sats
        int round_duration_seconds
        int round_cooldown_seconds
        int draw_animation_seconds
        int fee_rate_sat_vb
        int rbf_timeout_seconds
        bool paused
        datetime updated_at
    }
    pending_transactions {
        int id PK
        string kind "bet payout withdrawal"
        int round_id FK
        int withdrawal_id FK
        int user_id FK
        string current_txid
        int fee_rate_sat_vb
        text raw_tx_hex
        datetime broadcast_at
        datetime last_broadcast_at
        string status "building pending confirmed failed"
        string replaced_by_txid
        int attempt_count
        string failure_reason
        datetime updated_at
    }
    withdrawals {
        int id PK
        int user_id FK
        string external_address
        bigint amount_requested_sats
        bigint amount_sent_sats
        string txid
        string status "building broadcast confirmed failed"
        datetime created_at
        datetime confirmed_at
    }
    bug_reports {
        int id PK
        text description
        string contact
        int user_id FK
        string status "open read resolved"
        datetime created_at
    }
    audit_log {
        int id PK
        string event_type
        text payload_json
        int user_id FK
        int round_id FK
        datetime created_at
    }

Convenzioni comuni

  • [DAT-01] Ogni importo è un numero intero di satoshi (1 PLM = 100 000 000 sat), in una colonna BigInteger il cui nome finisce in _sats. Nessun importo passa mai per un float: anche il valore degli input letti dalla rete per un RBF è preso come intero dalla transazione grezza (B-40). Codice: app/db/models.py · app/tx/broadcast.py:_prevout_amount · Test: tests/unit/test_broadcast.py

  • [DAT-02] Ogni timestamp è in UTC. Si scrive con app/db/models.py:utcnow (default di colonna) o con datetime.now(timezone.utc); le colonne sono DateTime senza fuso, e SQLite restituisce valori naive. Chi legge un timestamp per confrontarlo con l'ora corrente lo marca UTC con .replace(tzinfo=timezone.utc), e ogni risposta API lo serializza con isoformat_utc, che aggiunge l'offset (B-35). Codice: app/db/models.py:utcnow · app/api/timeutil.py:isoformat_utc · app/rounds/service.py:round_deadline · Test: tests/unit/test_api_timeutil.py, tests/unit/test_scheduler.py

  • [DAT-03] I campi di stato (status, kind) sono stringhe libere (String(16)), senza CHECK né enum nel database. I valori ammessi sono solo quelli elencati in questo documento, e a garantirlo è esclusivamente il codice che li scrive; l'unico vincolo del database che dipende da un valore di stato è l'indice ix_rounds_single_active ([DAT-17]). Codice: app/db/models.py

  • [DAT-04] Le chiavi esterne sono dichiarate ma, su SQLite, non applicate: PRAGMA foreign_keys non viene attivato ([DAT-52]). Nessuna chiave esterna ha ON DELETE. Le sole cancellazioni fisiche nel codice sono quelle di una scommessa fallita (la riga round_participants e la sua pending_transactions, [DAT-24], [DAT-38]); nessun'altra tabella perde righe, quindi i riferimenti restano validi per costruzione, non per vincolo. Codice: app/db/base.py:_register_sqlite_pragmas · app/bets/service.py:_release_failed_bet · app/tx/reconcile.py:_abandon

  • [DAT-05] Ogni chiave primaria è un intero autoincrementale id, assegnato dal database al flush/commit. Nessuna tabella usa chiavi naturali come chiave primaria; le chiavi naturali (txid+vout, round_id+user_id, username) sono vincoli di unicità separati. Codice: app/db/models.py

Tabelle

users

Un account. Nasce alla registrazione e non viene mai cancellato.

Colonna Tipo Null Default Significato Scritta da
id Integer PK no autoincrement Identificativo; è il sub del JWT database
username String(64) no — Nome scelto dall'utente, conservato così come digitato app/auth/routes.py:register
password_hash String(256) no — Hash Argon2 della password register; app/api/routes/users.py:change_password; app/api/routes/admin.py:reset_user_password
derivation_index Integer, unico no — Indice BIP84 dell'indirizzo: m/84'/746'/0'/0/<index> register
address String(128), unico no — Indirizzo P2WPKH (plm1…) derivato da derivation_index; deposito, vincite e resto register
cached_balance_sats BigInteger no 0 Saldo confermato e spendibile, cache di utxo_events solo app/wallet/balance.py:recompute_balance
token_version Integer no 0 (ORM e server_default nel modello) Versione dei token di sessione; il JWT la porta nel claim tv change_password, reset_user_password
created_at DateTime no utcnow Istante della registrazione database/ORM
  • [DAT-06] Lo username è unico senza distinzione tra maiuscole e minuscole (B-57): lo garantisce l'indice unico funzionale ix_users_username_lower su lower(username), e registrazione e login confrontano con func.lower(...). Il valore è comunque memorizzato così come l'utente l'ha scritto, ed è quello mostrato in /admin e nel registro di audit. Poiché lo username ammesso è solo ASCII (^[A-Za-z0-9_.-]+$, 3–32 caratteri, validato dall'API), lower() è l'intera normalizzazione. Resta anche l'indice unico ix_users_username sulla colonna esatta, ridondante rispetto al primo ma innocuo. Codice: app/db/models.py:User · app/auth/routes.py:register · app/auth/routes.py:login · Test: tests/unit/test_auth.py

  • [DAT-07] derivation_index è assegnato alla registrazione come max(derivation_index) + 1 (0 per il primo utente). Se il commit fallisce per una collisione sull'indice (due registrazioni concorrenti) si ricalcola e si riprova, fino a 5 volte; una collisione sullo username invece non si riprova e risponde username_taken. derivation_index e address non vengono mai modificati dopo l'inserimento: l'indirizzo è permanente. Codice: app/auth/routes.py:register · app/wallet/hd.py:derive_user_address · Test: tests/unit/test_auth.py

  • [DAT-08] cached_balance_sats è uguale, in ogni momento osservabile, alla somma di amount_sats delle righe utxo_events dell'utente con spent_txid IS NULL. La fonte di verità è utxo_events; la colonna è scritta solo da recompute_balance, che va chiamata nella stessa transazione di qualunque modifica alle righe utxo_events dell'utente (un inserimento o un cambio di spent_txid da o verso NULL). I chiamanti sono: credit_confirmed_utxos, reinstate_reappeared_utxos, mark_utxos_spent_externally, place_bet, _release_failed_bet, request_withdrawal, _release_failed_withdrawal e il _abandon del riconciliatore. Il retarget di bump_fee ([DAT-14]) non la ricalcola perché sostituisce un txid con un altro e non cambia la somma. Il saldo "in attesa" mostrato all'utente non è una colonna: è calcolato al volo (vedi registrazione-e-depositi.md). Codice: app/wallet/balance.py:recompute_balance · app/deposits/service.py:credit_confirmed_utxos · app/bets/service.py:place_bet · app/withdrawals/service.py:request_withdrawal · app/tx/reconcile.py:_abandon · Test: tests/unit/test_balance.py, tests/unit/test_deposits.py

  • [DAT-09] token_version parte da 0 e aumenta di 1 a ogni cambio password (self-service) e a ogni reset da parte dell'amministratore; nient'altro la modifica. Un token il cui claim tv non coincide con il valore corrente è rifiutato (B-34). Un token senza claim tv vale come tv = 0. Codice: app/api/routes/users.py:change_password · app/api/routes/admin.py:reset_user_password · app/auth/dependencies.py:get_current_user · app/auth/security.py:decode_access_token · Test: tests/unit/test_security.py, tests/unit/test_admin.py

utxo_events

Un output confermato che paga l'indirizzo di un utente: un deposito, il resto di una sua scommessa o di un suo prelievo, una vincita. Dal punto di vista del database sono tutti uguali, perché arrivano tutti dallo stesso percorso (l'accredito dei depositi, vedi registrazione-e-depositi.md).

Colonna Tipo Null Default Significato Scritta da
id Integer PK no autoincrement — database
user_id FK users.id, indicizzata no — Proprietario dell'indirizzo pagato app/deposits/service.py:credit_confirmed_utxos
txid String(64) no — Transazione che ha creato l'output credit_confirmed_utxos
vout Integer no — Posizione dell'output credit_confirmed_utxos
amount_sats BigInteger no — Valore dell'output (value di listunspent) credit_confirmed_utxos
confirmed_height Integer no — Altezza del blocco riportata dal server (sempre > 0) credit_confirmed_utxos
confirmed_at DateTime no utcnow Istante dell'accredito nel database, non l'orario del blocco ORM
spent_txid String(64) sì NULL Chi ha speso l'output, vedi [DAT-12] vedi tabella sotto
  • [DAT-10] Una riga si inserisce solo per un output con height > 0 (almeno 1 conferma) che non è già presente, e solo dopo che gli altri server Electrum hanno corroborato lo stesso outpoint con lo stesso importo (B-59; la corroborazione è in connessione-electrum.md). L'inserimento è idempotente su (txid, vout), che è unico su tutta la tabella, non solo per utente. Le righe non si cancellano mai e, dopo l'inserimento, solo spent_txid cambia. Codice: app/deposits/service.py:find_new_credit_candidates · app/deposits/service.py:credit_confirmed_utxos · app/electrum/listener.py:ElectrumListener.refresh_user · Test: tests/unit/test_deposits.py, tests/unit/test_electrum_listener.py

  • [DAT-11] Gli UTXO del pool non sono in utxo_events: il pool non è un utente. Il pagamento sceglie i suoi input da una listunspent dell'indirizzo del pool, escludendo gli outpoint già impegnati da un pagamento non ancora risolto (letti dal raw_tx_hex delle righe pending_transactions di tipo payout in building/pending). Per lo stesso motivo l'abbandono di un pagamento non ha input da liberare. Codice: app/rounds/scheduler.py:_reserved_payout_outpoints · app/rounds/scheduler.py:RoundScheduler._trigger_payout · app/tx/reconcile.py:_abandon · Test: tests/unit/test_scheduler.py

  • [DAT-12] spent_txid ha tre significati:

    • NULL: l'output è spendibile e conta nel saldo;
    • un txid: l'output è impegnato da una transazione della piattaforma (scommessa o prelievo), dal momento della sua costruzione, prima ancora della trasmissione; resta impostato per sempre una volta confermata;
    • la stringa external-spend: l'output è sparito dalla listunspent e gli altri server hanno confermato che è stato speso fuori dalla piattaforma (B-29).

    Le transizioni ammesse sono queste, e nessun'altra funzione scrive la colonna:

    Da A Funzione Quando
    NULL txid app/bets/service.py:place_bet, app/withdrawals/service.py:request_withdrawal fase 1, prima del broadcast
    txid nuovo txid app/tx/broadcast.py:_retarget_txid_references bump RBF
    txid NULL app/bets/service.py:_release_failed_bet, app/withdrawals/service.py:_release_failed_withdrawal la rete ha rifiutato il broadcast
    txid NULL app/tx/reconcile.py:_release_inputs la transazione è stata abbandonata ([DAT-13])
    NULL external-spend app/deposits/service.py:mark_utxos_spent_externally spesa esterna corroborata
    external-spend NULL app/deposits/service.py:reinstate_reappeared_utxos l'outpoint ricompare come non speso

    Chi sceglie gli input di una nuova transazione considera solo le righe con spent_txid IS NULL. Codice: le funzioni in tabella · Test: tests/unit/test_deposits.py, tests/unit/test_bets.py, tests/unit/test_withdrawals.py, tests/unit/test_reconcile.py

  • [DAT-13] Il riconciliatore libera un input solo se il suo spent_txid è uguale al current_txid della riga che sta abbandonando: se nel frattempo lo stesso output risulta impegnato da un'altra transazione, quella rivendicazione è quella valida e non va toccata. Codice: app/tx/reconcile.py:_release_inputs · Test: tests/unit/test_reconcile.py

  • [DAT-14] Per ogni output impegnato, spent_txid è sempre il txid corrente della transazione che lo spende, cioè il current_txid della sua riga pending_transactions. Un bump RBF cambia il txid e quindi riscrive, nella stessa transazione del database, ogni spent_txid uguale al vecchio txid. Senza questo allineamento [DAT-13] non riconoscerebbe più gli input di una transazione rimpiazzata. Codice: app/tx/broadcast.py:bump_fee · app/tx/broadcast.py:_retarget_txid_references · Test: tests/unit/test_broadcast.py

rounds

Un round della lotteria, dall'apertura al pagamento confermato. I round chiusi restano nella tabella come storico.

Colonna Tipo Null Default Significato Scritta da
id Integer PK no autoincrement — database
status String(16) no "open" Fase del round, vedi [DAT-15] vedi diagramma
opened_at DateTime no utcnow Apertura; base della scadenza app/rounds/service.py:open_new_round_if_needed (default)
closed_at DateTime sì NULL Vedi [DAT-19] app/rounds/scheduler.py:RoundScheduler._tick; app/rounds/confirmation.py:_on_payout_confirmed
duration_seconds Integer no 600 (anche server_default) Durata di questo round, copiata da round_config all'apertura open_new_round_if_needed
cooldown_seconds Integer no 30 (anche server_default) Pausa dopo questo round, copiata da round_config all'apertura open_new_round_if_needed
drawing_started_at DateTime sì NULL Istante del passaggio a drawing RoundScheduler._close_and_draw
draw_block_height Integer sì NULL Altezza del blocco usato per l'estrazione _close_and_draw
draw_block_hash String(64) sì NULL Hash (esadecimale) di quel blocco _close_and_draw
seed_int String(128) sì NULL Il seme: int(draw_block_hash, 16) scritto in decimale (fino a 78 cifre, per questo è una stringa) _close_and_draw
winner_user_id FK users.id sì NULL Il vincitore _close_and_draw
pool_amount_sats BigInteger sì NULL Montepremi: somma delle scommesse confermate _close_and_draw
winner_amount_sats BigInteger sì NULL Quanto riceve il vincitore (70% meno la commissione di rete) RoundScheduler._trigger_payout
fee_amount_sats BigInteger sì NULL Quanto va a fee_address (30% più il resto dell'arrotondamento) _trigger_payout
payout_txid String(64) sì NULL Txid corrente della transazione di pagamento _trigger_payout; _retarget_txid_references; app/tx/reconcile.py:_abandon
  • [DAT-15] status assume solo i valori open, closing, drawing, paying_out, closed. I primi quattro sono gli stati attivi. Le transizioni sono esclusivamente queste:

    stateDiagram-v2
        [*] --> open: open_new_round_if_needed
        open --> closing: _tick, scadenza superata
        closing --> closed: _close_and_draw, nessun partecipante confermato
        closing --> drawing: _close_and_draw, nessuna scommessa in volo
        drawing --> paying_out: _close_and_draw, blocco corroborato
        paying_out --> closed: _on_payout_confirmed
        closed --> [*]
    
    Da A Funzione Condizione
    — open app/rounds/service.py:open_new_round_if_needed nessun round attivo, non in pausa, fee_address impostato, cooldown del round precedente trascorso
    open closing app/rounds/scheduler.py:RoundScheduler._tick now >= opened_at + duration_seconds
    closing closed RoundScheduler._close_and_draw nessuna scommessa building/broadcast e nessuna confirmed
    closing drawing RoundScheduler._close_and_draw nessuna scommessa building/broadcast, almeno una confirmed
    drawing paying_out RoundScheduler._close_and_draw _wait_for_next_block ha restituito un blocco corroborato
    paying_out closed app/rounds/confirmation.py:_on_payout_confirmed la transazione di pagamento ha 1 conferma

    Un pagamento abbandonato non cambia lo stato: il round resta paying_out con payout_txid = NULL e il pagamento viene ricostruito. L'unica altra scrittura di status fuori da questa tabella è la migrazione 8a1c4e7b2d90, che una tantum portò a closed i round attivi duplicati ([DAT-59]). Il significato di ogni fase è in ciclo-di-vita-del-round.md. Codice: le funzioni in tabella · Test: tests/unit/test_scheduler.py, tests/unit/test_rounds_service.py, tests/unit/test_confirmation.py

  • [DAT-16] place_bet protegge l'inserimento del partecipante con un compare-and-set sulla riga del round, nella stessa transazione: UPDATE rounds SET status = 'open' WHERE id = :id AND status = 'open'. Se l'aggiornamento non tocca esattamente una riga (o SQLite segnala un conflitto di scrittura) la transazione viene annullata e la scommessa rifiutata prima di qualunque broadcast (B-53). L'aggiornamento non cambia alcun valore: serve a prendere il lock di scrittura sulla riga, così il passaggio a closing e l'inserimento non possono intrecciarsi. Codice: app/bets/service.py:place_bet · Test: tests/unit/test_bets.py

  • [DAT-17] Al massimo un round attivo alla volta, come vincolo del database: ix_rounds_single_active è un indice unico sull'espressione costante (1), parziale sugli stati attivi (WHERE status IN ('open', 'closing', 'drawing', 'paying_out')). La tabella può quindi contenere quanti round closed si vuole, ma un secondo inserimento attivo fallisce con IntegrityError (B-09). open_new_round_if_needed gestisce il conflitto annullando il proprio inserimento e adottando il round del concorrente, con al massimo 3 tentativi. L'indice è dichiarato sia con sqlite_where sia con postgresql_where, così resta parziale anche su PostgreSQL. get_active_round ordina comunque per id decrescente e registra un errore se ne trova più di uno. Codice: app/db/models.py:Round · app/rounds/service.py:open_new_round_if_needed · app/rounds/service.py:get_active_round · Test: tests/unit/test_rounds_service.py

  • [DAT-18] duration_seconds e cooldown_seconds sono una copia dei valori di round_config presa quando il round si apre, e non cambiano più (B-61). La scadenza del round è opened_at + duration_seconds; la pausa prima del round successivo è il cooldown_seconds del round chiuso più recente, contata dal suo closed_at. Modificare round_config influisce quindi solo sui round aperti dopo. Codice: app/rounds/service.py:open_new_round_if_needed · app/rounds/service.py:round_deadline · Test: tests/unit/test_rounds_service.py, tests/unit/test_scheduler.py

  • [DAT-19] closed_at si scrive due volte. La prima al passaggio open → closing, con l'ora della chiusura delle scommesse. La seconda alla conferma del pagamento (paying_out → closed), dove viene sovrascritto con l'ora della conferma, così la pausa tra i round parte da quando il vincitore è stato davvero pagato. Un round chiuso senza partecipanti tiene il primo valore. Codice: app/rounds/scheduler.py:RoundScheduler._tick · app/rounds/confirmation.py:_on_payout_confirmed · Test: tests/unit/test_rounds_service.py (conteggio della pausa da closed_at; la seconda scrittura non ha un test dedicato)

  • [DAT-20] drawing_started_at si scrive una sola volta, al passaggio a drawing. Da lì si misura quanto l'estrazione sta aspettando un blocco (draw_waiting_since in GET /rounds/current, le voci draw_stalled del registro, B-36). Codice: app/rounds/scheduler.py:RoundScheduler._close_and_draw · app/api/routes/rounds.py:current_round · Test: tests/unit/test_rounds_route.py

  • [DAT-21] draw_block_height, draw_block_hash, seed_int, winner_user_id e pool_amount_sats si scrivono insieme, nella stessa transazione che porta il round a paying_out, e poi non cambiano più. pool_amount_sats è la somma di bet_amount_sats dei soli partecipanti confirmed, gli stessi fra cui si estrae il vincitore. Codice: app/rounds/scheduler.py:RoundScheduler._close_and_draw · Test: tests/unit/test_scheduler.py

  • [DAT-22] winner_amount_sats, fee_amount_sats e payout_txid si scrivono nella fase 3 di _trigger_payout, insieme alla riga pending_transactions del pagamento e prima del broadcast (B-25). payout_txid segue poi il txid corrente a ogni bump RBF, e torna NULL quando il riconciliatore abbandona il pagamento. winner_amount_sats e fee_amount_sats invece non vengono azzerati da un abbandono: li riscrive il tentativo successivo. Un bump RBF non li cambia, perché riduce solo il resto del pool. Codice: app/rounds/scheduler.py:RoundScheduler._trigger_payout · app/tx/broadcast.py:_retarget_txid_references · app/tx/reconcile.py:_abandon · Test: tests/unit/test_scheduler.py, tests/unit/test_broadcast.py, tests/unit/test_reconcile.py

round_participants

Una scommessa: un utente in un round.

Colonna Tipo Null Default Significato Scritta da
id Integer PK no autoincrement — database
round_id FK rounds.id, indicizzata no — Il round app/bets/service.py:place_bet
user_id FK users.id, indicizzata no — Chi scommette place_bet
bet_amount_sats BigInteger no — Quanto arriva davvero al pool: costo della scommessa meno la commissione di rete place_bet
bet_txid String(64) no — Txid corrente della scommessa place_bet; app/tx/broadcast.py:_retarget_txid_references
broadcast_at DateTime no utcnow Ordine di estrazione, vedi [DAT-25] place_bet
confirmed_at DateTime sì NULL Istante in cui la scommessa è risultata confermata app/bets/confirmation.py:_on_bet_confirmed
status String(16) no "broadcast" (mai usato: si scrive sempre esplicitamente) Vedi [DAT-24] vedi diagramma
  • [DAT-23] Al massimo una scommessa per utente per round: vincolo unico su (round_id, user_id). place_bet lo verifica già prima di costruire la transazione (already_betting). Il vincolo è anche la chiave con cui i gestori di conferma, bump e riconciliazione ritrovano la riga, invece del txid che cambia. Codice: app/db/models.py:RoundParticipant · app/bets/service.py:place_bet · app/bets/confirmation.py:_on_bet_confirmed · Test: tests/unit/test_bets.py

  • [DAT-24] status assume solo i valori building, broadcast, confirmed. Non esiste uno stato di fallimento: una scommessa che non va a buon fine viene cancellata, e l'utente non risulta nel round.

    stateDiagram-v2
        [*] --> building: place_bet fase 1
        building --> broadcast: place_bet fase 2 / _promote
        building --> confirmed: _on_bet_confirmed
        broadcast --> confirmed: _on_bet_confirmed
        building --> [*]: cancellata (_release_failed_bet / _abandon)
        broadcast --> [*]: cancellata (_abandon)
        confirmed --> [*]
    
    Da A Funzione
    — building app/bets/service.py:place_bet (fase 1, prima del broadcast)
    building broadcast place_bet (fase 2, broadcast riuscito); app/tx/reconcile.py:_promote
    building, broadcast confirmed app/bets/confirmation.py:_on_bet_confirmed
    building cancellata app/bets/service.py:_release_failed_bet (broadcast rifiutato)
    building, broadcast cancellata app/tx/reconcile.py:_abandon

    Se il compare-and-set di [DAT-16] fallisce, la riga building non arriva nemmeno al commit. Codice: le funzioni in tabella · Test: tests/unit/test_bets.py, tests/unit/test_confirmation.py, tests/unit/test_reconcile.py

  • [DAT-25] broadcast_at è l'istante della fase 1 di place_bet, appena prima del broadcast, e non viene mai riscritto (nemmeno da un bump RBF). L'estrazione ordina i partecipanti confermati per broadcast_at crescente. Codice: app/bets/service.py:place_bet · app/rounds/scheduler.py:RoundScheduler._close_and_draw · Test: tests/unit/test_scheduler.py

  • [DAT-26] bet_amount_sats è l'importo del primo output della scommessa (recipient_sats), cioè round_config.bet_amount_sats in vigore al momento della scommessa meno la commissione di rete. Non cambia più: un bump RBF riduce il resto dell'utente, non l'output verso il pool. Per questo il montepremi si calcola sommando questa colonna, non moltiplicando il numero di partecipanti per il costo attuale (B-11). Codice: app/bets/service.py:place_bet · app/wallet/psbt_builder.py:build_signed_transaction · app/api/routes/rounds.py:current_round · Test: tests/unit/test_bets.py

  • [DAT-27] Il numero di righe di un round, contando tutti gli stati, non supera MAX_PARTICIPANTS_PER_ROUND (400): la 401ª scommessa è rifiutata con round_full prima che si muova denaro (B-52). Codice: app/bets/service.py:place_bet · app/wallet/psbt_builder.py · Test: tests/unit/test_bets.py

round_config

I parametri operativi, in una sola riga, modificabili a caldo da /admin senza riavviare (vedi decisioni-e-limiti.md).

Colonna Tipo Default Limiti accettati da PUT /admin/config Significato Quando ha effetto
id Integer PK autoincrement — — —
fee_address String(128), non null nessun default nel modello; la riga nasce con "" indirizzo PLM bech32 valido Dove va il 30% di ogni round Subito: si legge a ogni costruzione del pagamento, quindi vale anche per il round in corso se il suo pagamento non è ancora stato costruito
bet_amount_sats BigInteger 1_000_000_000 (10 PLM) > 0, ≤ 100 000 PLM Costo della scommessa; anche il prelievo minimo Subito, per le scommesse e i prelievi successivi; le scommesse già piazzate tengono il proprio importo ([DAT-26])
round_duration_seconds Integer 600 30 s – 7 giorni Durata del round Dal round successivo ([DAT-18])
round_cooldown_seconds Integer 30 0 s – 24 h Pausa dopo la chiusura Dal round successivo: la pausa dopo il round in corso è quella copiata alla sua apertura ([DAT-18])
draw_animation_seconds Integer 20 0 – 600 s Durata minima dell'animazione dell'estrazione nel frontend; non condiziona l'estrazione vera Subito (letto da GET /rounds/current)
fee_rate_sat_vb Integer 1 1 – MAX_FEE_RATE_SAT_VB (10 000) Commissione di rete delle nuove transazioni Subito, per le transazioni costruite dopo; quelle in volo tengono il proprio fee_rate_sat_vb
rbf_timeout_seconds Integer 900 60 s – 7 giorni Dopo quanto senza conferme si fa un bump RBF Subito, anche per le transazioni già in volo (il bumper lo rilegge a ogni giro)
paused Boolean False non modificabile da PUT /admin/config Pausa di manutenzione Subito per l'apertura del round successivo; il round in corso non è toccato
updated_at DateTime utcnow, aggiornato a ogni modifica (onupdate) — Ultima modifica della riga ORM
  • [DAT-28] La tabella contiene al più una riga, creata alla prima lettura con i default di colonna di RoundConfig e fee_address = "". Tutti leggono la configurazione attraverso get_round_config, che prende la prima riga trovata. Il database non impone l'unicità della riga: la garanzia è che solo get_round_config ne inserisce una. I default di un'istanza nuova sono quelli del modello, non variabili d'ambiente. Codice: app/rounds/config.py:get_round_config · app/db/models.py:RoundConfig · Test: tests/unit/test_admin.py

  • [DAT-29] Le colonne modificabili da PUT /admin/config sono esattamente fee_address, bet_amount_sats, round_duration_seconds, round_cooldown_seconds, fee_rate_sat_vb, rbf_timeout_seconds, draw_animation_seconds, entro i limiti della tabella sopra. Ogni modifica effettiva scrive una voce config_updated con il valore prima e dopo, e un valore uguale a quello attuale non conta come modifica. Codice: app/api/routes/admin.py:update_config · app/api/routes/admin.py:RoundConfigUpdate · Test: tests/unit/test_admin.py

  • [DAT-30] paused si modifica solo con POST /admin/pause e POST /admin/resume, che scrivono lottery_paused e lottery_resumed nel registro (B-10). Codice: app/api/routes/admin.py:pause_lottery · app/api/routes/admin.py:resume_lottery · Test: tests/unit/test_admin.py

  • [DAT-31] Con fee_address vuoto (o di soli spazi) non si apre nessun round (B-66). Una volta impostato, l'API non permette di svuotarlo di nuovo: la validazione rifiuta la stringa vuota come indirizzo non valido. Codice: app/rounds/service.py:rounds_can_open · app/api/routes/admin.py:RoundConfigUpdate · Test: tests/unit/test_rounds_service.py, tests/unit/test_admin.py

pending_transactions

Ogni transazione in uscita dalla piattaforma: scommesse, prelievi e pagamenti. È la riga su cui lavorano il poller delle conferme, il bumper RBF e il riconciliatore (vedi transazioni-e-riconciliazione.md).

Colonna Tipo Null Default Significato Scritta da
id Integer PK no autoincrement — database
kind String(16) no — bet, withdrawal o payout chi crea la riga
round_id FK rounds.id sì NULL Il round (bet, payout) place_bet; _trigger_payout
withdrawal_id FK withdrawals.id sì NULL Il prelievo (withdrawal) request_withdrawal
user_id FK users.id sì NULL Il mittente (bet, withdrawal); NULL per payout place_bet; request_withdrawal
current_txid String(64) no — Txid della versione più recente della transazione creazione; bump_fee
fee_rate_sat_vb Integer no — Commissione della versione corrente, in sat/vB creazione; bump_fee
raw_tx_hex Text no — Transazione firmata della versione corrente creazione; bump_fee
broadcast_at DateTime no utcnow Prima trasmissione, vedi [DAT-35] ORM, alla creazione
last_broadcast_at DateTime no utcnow Ultima trasmissione, vedi [DAT-35] ORM; bump_fee
status String(16) no "pending" (mai usato: si crea sempre building) Vedi [DAT-33] vedi diagramma
replaced_by_txid String(64) sì NULL Il txid sostituito dall'ultimo bump (nonostante il nome, punta all'indietro) bump_fee
attempt_count Integer no 1 1 + numero di bump riusciti bump_fee
failure_reason String(128) sì NULL Perché la transazione è fallita; solo con status = failed _abandon; _release_failed_withdrawal
updated_at DateTime no utcnow, onupdate Ultima modifica ORM

Le funzioni citate sono app/bets/service.py:place_bet, app/withdrawals/service.py:request_withdrawal, app/rounds/scheduler.py:RoundScheduler._trigger_payout, app/tx/broadcast.py:bump_fee, app/tx/reconcile.py:_abandon e app/withdrawals/service.py:_release_failed_withdrawal.

  • [DAT-32] Le colonne di collegamento dipendono da kind:

    kind round_id user_id withdrawal_id Firmata con Riga di dominio collegata
    bet il round lo scommettitore NULL chiave dell'utente round_participants (round_id, user_id)
    withdrawal NULL chi preleva il prelievo chiave dell'utente withdrawals (withdrawal_id)
    payout il round NULL NULL chiave del pool rounds (round_id)

    I gestori delle conferme e del riconciliatore ritrovano la riga di dominio da questi identificativi, che non cambiano, e non dal txid, che cambia a ogni bump (B-02). L'indirizzo di cui controllare la cronologia si ricava da kind e user_id. Codice: app/tx/pending_address.py:own_address_for · app/tx/broadcast.py:_signing_context · app/bets/confirmation.py:_on_bet_confirmed · app/withdrawals/confirmation.py:_on_withdrawal_confirmed · app/rounds/confirmation.py:_on_payout_confirmed · Test: tests/unit/test_confirmation.py, tests/unit/test_pending_address.py

  • [DAT-33] status assume solo i valori building, pending, confirmed, failed; gli ultimi due sono terminali.

    stateDiagram-v2
        [*] --> building: creazione, prima del broadcast
        building --> pending: broadcast riuscito / _promote
        building --> failed: _release_failed_withdrawal / _abandon
        building --> [*]: cancellata (_release_failed_bet)
        pending --> confirmed: poll_once
        pending --> failed: _abandon
        confirmed --> [*]
        failed --> [*]
    
    Da A Funzione
    — building app/bets/service.py:place_bet, app/withdrawals/service.py:request_withdrawal, app/rounds/scheduler.py:RoundScheduler._trigger_payout
    building pending le stesse tre, dopo il broadcast riuscito; app/tx/reconcile.py:_promote
    pending confirmed app/tx/confirmation.py:poll_once (poi il gestore del kind)
    building, pending failed app/tx/reconcile.py:_abandon
    building failed app/withdrawals/service.py:_release_failed_withdrawal
    building cancellata app/bets/service.py:_release_failed_bet

    Un pagamento il cui broadcast fallisce resta building finché il riconciliatore non lo risolve. Il poller delle conferme guarda solo le righe pending. Codice: le funzioni in tabella · Test: tests/unit/test_confirmation.py, tests/unit/test_reconcile.py, tests/unit/test_scheduler.py, tests/unit/test_bets.py, tests/unit/test_withdrawals.py

  • [DAT-34] Dopo la creazione, current_txid, raw_tx_hex, fee_rate_sat_vb, replaced_by_txid, attempt_count e last_broadcast_at cambiano solo in bump_fee, tutti nella stessa transazione del database, solo dopo che il nuovo broadcast è riuscito e solo per una riga ancora pending. fee_rate_sat_vb registra la commissione effettivamente pagata dalla nuova versione, non quella obiettivo. replaced_by_txid conserva solo il txid immediatamente precedente: la catena completa dei rimpiazzi non è memorizzata. Codice: app/tx/broadcast.py:bump_fee · Test: tests/unit/test_broadcast.py

  • [DAT-35] broadcast_at è la prima trasmissione e non viene mai riscritta: da lì il riconciliatore misura dopo quanto abbandonare (120 s per building, 6 h per pending). last_broadcast_at è l'ultima trasmissione: la aggiorna bump_fee e da lì should_bump misura se serve un altro bump. Tenerle separate impedisce che bump ripetuti rinviino all'infinito l'abbandono (B-27). Codice: app/tx/reconcile.py:_is_due · app/tx/broadcast.py:should_bump · app/tx/broadcast.py:bump_fee · Test: tests/unit/test_broadcast.py, tests/unit/test_reconcile.py

  • [DAT-36] failure_reason si scrive solo quando la riga passa a failed, troncato a 128 caratteri: "not found on chain" dal riconciliatore, il messaggio d'errore della rete per un prelievo rifiutato. Codice: app/tx/reconcile.py:_abandon · app/withdrawals/service.py:_release_failed_withdrawal · Test: tests/unit/test_reconcile.py

  • [DAT-37] Per le righe bet e withdrawal in building/pending, raw_tx_hex è la fonte del saldo "in attesa" (gli output che tornano all'indirizzo dell'utente). Per le righe payout in building/pending definisce gli outpoint del pool già impegnati ([DAT-11]). Per ogni riga abbandonata definisce gli input da liberare ([DAT-13]). Codice: app/wallet/balance.py:compute_pending_balance · app/rounds/scheduler.py:_reserved_payout_outpoints · app/tx/reconcile.py:_release_inputs · Test: tests/unit/test_balance.py, tests/unit/test_scheduler.py, tests/unit/test_reconcile.py

  • [DAT-38] Le righe pending_transactions non si cancellano, con una sola eccezione: la riga bet il cui broadcast è stato rifiutato, cancellata insieme al partecipante da _release_failed_bet. Un prelievo rifiutato invece lascia la riga, in failed. Codice: app/bets/service.py:_release_failed_bet · app/withdrawals/service.py:_release_failed_withdrawal · Test: tests/unit/test_bets.py, tests/unit/test_withdrawals.py

withdrawals

Una richiesta di prelievo verso un indirizzo esterno. A differenza di una scommessa, resta nella tabella anche quando fallisce: è un'istruzione data dall'utente.

Colonna Tipo Null Default Significato Scritta da
id Integer PK no autoincrement — database
user_id FK users.id, indicizzata no — Chi preleva app/withdrawals/service.py:request_withdrawal
external_address String(128) no — Destinatario: un indirizzo PLM valido, diverso da quello dell'utente request_withdrawal
amount_requested_sats BigInteger no — Importo chiesto dall'utente request_withdrawal
amount_sent_sats BigInteger sì (sempre valorizzata dal codice) NULL Quanto riceve il destinatario, vedi [DAT-40] request_withdrawal
txid String(64) sì NULL Txid corrente; NULL dopo un fallimento request_withdrawal; _retarget_txid_references; _release_failed_withdrawal; _abandon
status String(16) no "pending" (mai usato: si crea sempre building) Vedi [DAT-39] vedi diagramma
created_at DateTime no utcnow Istante della richiesta ORM
confirmed_at DateTime sì NULL Istante in cui il prelievo è risultato confermato app/withdrawals/confirmation.py:_on_withdrawal_confirmed
  • [DAT-39] status assume solo i valori building, broadcast, confirmed, failed; gli ultimi due sono terminali. Il passaggio a failed azzera txid.

    stateDiagram-v2
        [*] --> building: request_withdrawal
        building --> broadcast: broadcast riuscito / _promote
        building --> confirmed: _on_withdrawal_confirmed
        broadcast --> confirmed: _on_withdrawal_confirmed
        building --> failed: _release_failed_withdrawal / _abandon
        broadcast --> failed: _abandon
        confirmed --> [*]
        failed --> [*]
    
    Da A Funzione
    — building app/withdrawals/service.py:request_withdrawal
    building broadcast request_withdrawal (broadcast riuscito); app/tx/reconcile.py:_promote
    building, broadcast confirmed app/withdrawals/confirmation.py:_on_withdrawal_confirmed
    building failed app/withdrawals/service.py:_release_failed_withdrawal
    building, broadcast failed app/tx/reconcile.py:_abandon

    Codice: le funzioni in tabella · Test: tests/unit/test_withdrawals.py, tests/unit/test_reconcile.py

  • [DAT-40] amount_requested_sats è ciò che l'utente ha chiesto; amount_sent_sats è l'output effettivo verso il destinatario: l'importo chiesto meno la commissione di rete e, per un prelievo dell'intero saldo, meno quanto serve a lasciare un resto di DUST_LIMIT_SATS (B-62). Le due colonne si scrivono insieme alla creazione e non cambiano più: un bump RBF riduce il resto, non l'output del destinatario. Codice: app/withdrawals/service.py:request_withdrawal · app/wallet/psbt_builder.py:build_signed_transaction · Test: tests/unit/test_withdrawals.py

bug_reports

Una segnalazione inviata da /report-bug, anche senza login.

Colonna Tipo Null Default Significato Scritta da
id Integer PK no autoincrement — database
description Text no — Testo della segnalazione, già ripulito dagli spazi, 1–2000 caratteri (limite dell'API) app/api/routes/bug_reports.py:create_bug_report
contact String(256) sì NULL Recapito facoltativo; una stringa vuota diventa NULL create_bug_report
user_id FK users.id sì NULL Chi ha segnalato, se aveva fatto il login create_bug_report
status String(16) no "open" open, read o resolved create_bug_report (default); app/api/routes/admin.py:update_bug_report_status
created_at DateTime no utcnow Istante della segnalazione ORM
  • [DAT-41] status assume solo i valori open, read, resolved. Nasce open. Poi lo cambia solo l'amministratore, verso uno qualunque dei tre valori (il percorso previsto è open → read → resolved, ma anche i ritorni indietro sono ammessi). Ogni cambio effettivo scrive bug_report_status_changed con il valore prima e dopo, e riassegnare lo stesso valore non scrive nulla (B-60). read esiste per permettere a chi ha segnalato di distinguere "visto" da "risolto". Codice: app/api/routes/admin.py:update_bug_report_status · app/api/routes/bug_reports.py:create_bug_report · Test: tests/unit/test_bug_reports.py

audit_log

Il registro di audit: chi ha fatto cosa, e quando. Il catalogo dei tipi di evento e dei loro payload è in audit-log.md.

Colonna Tipo Null Default Significato
id Integer PK no autoincrement Ordine di scrittura
event_type String(32) no — Tipo di evento (es. bet_placed, payout_failed)
payload_json Text no — Dettagli dell'evento, oggetto JSON serializzato
user_id FK users.id sì NULL Utente interessato, se c'è
round_id FK rounds.id sì NULL Round interessato, se c'è
created_at DateTime no utcnow Istante di scrittura
  • [DAT-42] Il registro è in sola aggiunta: le righe si inseriscono solo con write_audit_log, che aggiunge la riga alla sessione del chiamante senza fare commit, così la voce entra nella stessa transazione del cambiamento che descrive. Nessun codice aggiorna o cancella righe di audit_log. payload_json è Text (B-47). Codice: app/audit/log.py:write_audit_log · Test: tests/unit/test_models_text_columns.py, tests/unit/test_admin.py

  • [DAT-43] Il registro non è solo informativo. Il created_at dell'ultima voce payout_failed di un round fa da orologio per il riprovare del pagamento: un nuovo tentativo parte solo 60 s dopo (B-26). Per questo ogni uscita anticipata di _trigger_payout scrive una voce payout_failed. Codice: app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due · app/rounds/scheduler.py:RoundScheduler._log_payout_failure · Test: tests/unit/test_scheduler.py

alembic_version

Tabella di servizio di Alembic: una riga con la revisione applicata (version_num). Non è nel modello e il codice applicativo non la legge.

Indici e vincoli

Nome Tabella Definizione Perché
PK id tutte intero autoincrementale [DAT-05]
ix_users_username users UNIQUE (username) unicità esatta, dallo schema iniziale; ridondante rispetto alla riga sotto
ix_users_username_lower users UNIQUE (lower(username)) username senza distinzione di maiuscole, [DAT-06] (B-57)
unico derivation_index users UNIQUE un indice HD, quindi un indirizzo, per utente, [DAT-07]
unico address users UNIQUE un indirizzo non appartiene a due utenti
unico (txid, vout) utxo_events UNIQUE un outpoint si accredita una volta sola, [DAT-10]
ix_utxo_events_user_id utxo_events indice su user_id calcolo del saldo e selezione degli UTXO per utente
ix_rounds_single_active rounds UNIQUE ((1)) WHERE status IN ('open','closing','drawing','paying_out') al massimo un round attivo, [DAT-17] (B-09)
unico (round_id, user_id) round_participants UNIQUE una scommessa per utente per round, [DAT-23]
ix_round_participants_round_id round_participants indice partecipanti di un round
ix_round_participants_user_id round_participants indice round di un utente (last-round-result)
ix_withdrawals_user_id withdrawals indice prelievi di un utente

Non ci sono indici su pending_transactions, audit_log o bug_reports oltre alla chiave primaria. In particolare pending_transactions.status e audit_log.event_type/round_id, usati dai filtri dei task in background, sono letti con una scansione completa.

Invarianti tra tabelle

  • [DAT-44] Prima si scrive, poi si trasmette. Ogni transazione in uscita esiste nel database prima di essere trasmessa: la riga pending_transactions in building, la riga di dominio (partecipante in building, prelievo in building, oppure payout_txid sul round) e, per scommesse e prelievi, gli input già marcati con spent_txid, tutto in un commit che precede il broadcast (B-08, B-25). Nessuna sessione del database resta aperta durante una chiamata di rete. Codice: app/bets/service.py:place_bet · app/withdrawals/service.py:request_withdrawal · app/rounds/scheduler.py:RoundScheduler._trigger_payout · Test: tests/unit/test_bets.py, tests/unit/test_scheduler.py

  • [DAT-45] Un solo txid valido. Per ogni transazione non terminale, pending_transactions.current_txid coincide con il txid registrato sulla riga di dominio (round_participants.bet_txid, withdrawals.txid, rounds.payout_txid) e con lo spent_txid di ogni input impegnato ([DAT-14]). bump_fee li aggiorna tutti nella stessa transazione del database. Per un pagamento, payout_txid si aggiorna solo se era ancora uguale al vecchio txid. Codice: app/tx/broadcast.py:_retarget_txid_references · Test: tests/unit/test_broadcast.py

  • [DAT-46] Il montepremi si conserva. Per un round pagato: pool_amount_sats = somma dei bet_amount_sats dei partecipanti confirmed; fee_amount_sats = pool_amount_sats − 70% (arrotondato per difetto) di pool_amount_sats; winner_amount_sats = quel 70% meno la commissione di rete del pagamento. La commissione di rete esce quindi dalla quota del vincitore, mai da quella della piattaforma. La ripartizione è nel codice, non in round_config. Codice: app/rounds/service.py:winner_share · app/rounds/scheduler.py:RoundScheduler._trigger_payout · app/wallet/psbt_builder.py:build_payout_transaction · Test: tests/unit/test_payout_builder.py, tests/unit/test_scheduler.py

  • [DAT-47] Un round non si chiude con scommesse in volo. Finché un round ha partecipanti in building o broadcast resta in closing. Il conteggio si ripete nella stessa sessione che legge i partecipanti da estrarre (B-53), e l'attesa finisce perché ogni riga in volo viene prima o poi confermata o cancellata dal riconciliatore. Codice: app/rounds/scheduler.py:RoundScheduler._tick · app/rounds/scheduler.py:RoundScheduler._close_and_draw · Test: tests/unit/test_scheduler.py

  • [DAT-48] Un solo pagamento per round. Non si costruisce un pagamento per un round che ha già una riga payout in building o pending, e gli outpoint del pool impegnati da qualunque pagamento non risolto sono esclusi dalla selezione ([DAT-11]). Codice: app/rounds/scheduler.py:RoundScheduler._trigger_payout · app/rounds/scheduler.py:_reserved_payout_outpoints · Test: tests/unit/test_scheduler.py

  • [DAT-49] Le transazioni ritrovano la loro riga senza il txid. Le righe collegate a una pending_transactions si ritrovano tramite round_id, user_id e withdrawal_id ([DAT-32]); il txid serve solo come ripiego per righe senza collegamento. Codice: app/bets/confirmation.py:_on_bet_confirmed · app/rounds/confirmation.py:_on_payout_confirmed · Test: tests/unit/test_confirmation.py

Motore e sessioni

  • [DAT-50] C'è un solo motore asincrono per processo, creato all'importazione di app/db/base.py da settings.database_url (DATABASE_URL; predefinito sqlite+aiosqlite:///./plm_lottery.db, e nel container sqlite+aiosqlite:////app/db_data/plm_lottery.db, montato da ./data/db). Le sessioni si creano con AsyncSessionLocal (async_sessionmaker con expire_on_commit=False): gli oggetti letti restano utilizzabili dopo il commit. Codice: app/db/base.py · app/config.py

  • [DAT-51] Gli endpoint HTTP ricevono una sessione per richiesta dalla dipendenza get_session. I task in background ricevono AsyncSessionLocal come factory e aprono sessioni brevi, chiuse prima di ogni chiamata di rete. Il commit è sempre esplicito: get_session non ne fa alla fine della richiesta. Codice: app/db/session.py:get_session · app/main.py · Test: tests/unit/test_broadcast.py

  • [DAT-52] Su SQLite ogni nuova connessione esegue PRAGMA journal_mode=WAL, PRAGMA synchronous=NORMAL e PRAGMA busy_timeout=5000 (B-39): lettori e scrittori non si bloccano a vicenda, e un secondo scrittore aspetta fino a 5 s invece di fallire subito. Con un altro dialetto non viene eseguito nulla. PRAGMA foreign_keys non è tra questi, da cui [DAT-04]. Codice: app/db/base.py:_register_sqlite_pragmas · Test: tests/unit/test_db_base.py

Migrazioni

Politica

  • [DAT-53] Lo schema si cambia solo con una revisione Alembic in migrations/versions/. Si modifica app/db/models.py, poi si genera la revisione con alembic revision --autogenerate -m "..." e la si rilegge e corregge a mano prima del commit. La catena è lineare, senza rami. migrations/env.py prende l'URL da settings.database_url e i metadati da Base.metadata, e non abilita render_as_batch: le modifiche a colonne esistenti su SQLite vanno scritte a mano dentro op.batch_alter_table. Codice: migrations/env.py · app/db/models.py

  • [DAT-54] Le migrazioni si applicano solo all'avvio del container: il comando del Dockerfile controlla che esista la master key, esegue alembic upgrade head e solo se riesce avvia uvicorn. Una migrazione che fallisce impedisce quindi al server di partire invece di lasciarlo girare su uno schema a metà. alembic upgrade head non va mai lanciato a mano. Codice: Dockerfile

  • [DAT-55] Una colonna NOT NULL aggiunta a una tabella che può avere già righe deve dare un valore a quelle righe nella migrazione stessa, in uno di due modi: un server_default temporaneo, rimosso subito dopo in modo che le righe nuove prendano il default dell'ORM (round_config, users.token_version); oppure un backfill esplicito calcolato dai dati (last_broadcast_at copiato da broadcast_at; rounds.duration_seconds/cooldown_seconds copiati da round_config, così il round in corso non cambia durata con la migrazione). Codice: migrations/versions/53cc70d16e63_add_operational_params_to_round_config.py · migrations/versions/861e76aaf34c_add_last_broadcast_at_to_pending_.py · migrations/versions/283844a44b4a_snapshot_round_timing_onto_the_round_.py

  • [DAT-56] Gli indici su espressioni (ix_rounds_single_active, ix_users_username_lower) vanno scritti a mano nella migrazione: l'autogenerate su SQLite non li sa confrontare e li ignora con un avviso. Inoltre op.batch_alter_table su SQLite ricrea la tabella e perde senza errori gli indici su espressioni, perché SQLAlchemy non li legge dallo schema. Una migrazione futura che usi batch_alter_table su rounds o users deve quindi ricreare esplicitamente l'indice relativo. Oggi nessuna migrazione lo fa dopo la creazione di quegli indici. Codice: migrations/versions/8a1c4e7b2d90_add_failure_reason_and_single_active_round_index.py · migrations/versions/c1d4a97b5e10_case_insensitive_usernames.py

  • [DAT-57] Una migrazione che incontra dati incompatibili con un nuovo vincolo si comporta in uno di due modi. Se esiste una correzione senza ambiguità, la applica e procede. Se correggere significherebbe decidere sulla proprietà dei fondi, si rifiuta, e il server non parte ([DAT-54]). Le due migrazioni di questo tipo sono [DAT-58] e [DAT-59].

  • [DAT-58] c1d4a97b5e10 (username senza distinzione di maiuscole, B-57) si rifiuta di procedere se due account esistenti hanno username che differiscono solo per maiuscole/minuscole: solleva un errore che elenca i gruppi in collisione e non crea l'indice. Non sceglie quale account rinominare, perché entrambi possono contenere fondi: l'operatore rinomina a mano un account (e avvisa l'utente), poi riavvia (vedi operativita.md). Codice: migrations/versions/c1d4a97b5e10_case_insensitive_usernames.py

  • [DAT-59] 8a1c4e7b2d90 (indice del round attivo unico, B-09), se trova più round attivi, porta a closed tutti tranne il più recente prima di creare l'indice. È la stessa scelta che get_active_round faceva già in silenzio. Codice: migrations/versions/8a1c4e7b2d90_add_failure_reason_and_single_active_round_index.py

Nessun test esegue le migrazioni. I test unitari creano lo schema direttamente dal modello (Base.metadata.create_all), quindi verificano il modello, non la catena di revisioni.

Storia dello schema

In ordine di applicazione (ogni riga rivede la precedente):

Revisione Contenuto
274efdcbfbcc Schema iniziale: round_config (solo fee_address, bet_amount_sats, updated_at), users, rounds, utxo_events, withdrawals, audit_log, pending_transactions, round_participants
53cc70d16e63 round_config: round_duration_seconds, round_cooldown_seconds, min_amount_sats, fee_rate_sat_vb, rbf_timeout_seconds (valori prima presi da app/config.py)
1db52f3a7c67 round_config.draw_animation_seconds
5f2079b95b33 round_config.paused
6cb50b29f64c Rimosso round_config.min_amount_sats: il prelievo minimo è il costo della scommessa
8a1c4e7b2d90 pending_transactions.failure_reason; indice ix_rounds_single_active (B-04, B-09)
861e76aaf34c pending_transactions.last_broadcast_at, con backfill da broadcast_at (B-27)
943dbd74d983 users.token_version (B-34)
9ef6a51509f7 rounds.drawing_started_at (B-36)
87a0c640355c audit_log.payload_json e pending_transactions.raw_tx_hex da VARCHAR a Text (B-47)
ee8508d98d34 Tabella bug_reports, con un booleano resolved
be71fdac734e bug_reports.resolved sostituito da status a tre valori (resolved → 'resolved', il resto → 'open')
c1d4a97b5e10 Indice ix_users_username_lower (B-57), [DAT-58]
283844a44b4a rounds.duration_seconds/cooldown_seconds (B-61), backfill dalla configurazione attuale — head

Differenze note tra modello e schema migrato

  • users.token_version: il modello dichiara server_default="0", ma la migrazione 943dbd74d983 rimuove il default del server dopo il backfill. Un database migrato non ha quindi un default a livello SQL. Non ha effetti pratici, perché ogni inserimento passa dall'ORM che fornisce 0, e l'autogenerate non confronta i default del server, quindi non lo segnala.
  • rounds.duration_seconds/cooldown_seconds: server_default presente sia nel modello sia nel database migrato.

Scelte specifiche di SQLite

  • Un solo file, un solo processo. SQLite con WAL regge i sei task in background e le richieste HTTP di un singolo processo uvicorn ([DAT-52]). I lock per utente e il canale SSE sono comunque in memoria, quindi più processi non sono supportati in nessun caso (vedi decisioni-e-limiti.md).
  • Serializzazione degli scrittori. Il compare-and-set di [DAT-16] si basa sul lock di scrittura di SQLite: un conflitto tra snapshot arriva come OperationalError e viene trattato come "round non più aperto".
  • Indice parziale su espressione. ix_rounds_single_active usa sia un'espressione ((1)) sia una clausola WHERE; SQLite e PostgreSQL supportano entrambe, e il modello dichiara la clausola per tutti e due (sqlite_where, postgresql_where). La migrazione la crea invece con SQL grezzo (CREATE UNIQUE INDEX ... ON rounds ((1)) WHERE ...).
  • Timestamp senza fuso. SQLite non conserva il fuso orario ([DAT-02]).
  • Chiavi esterne non applicate ([DAT-04]).
  • Text invece di VARCHAR senza lunghezza per i contenuti di lunghezza arbitraria (raw_tx_hex, payload_json, description), così lo schema resta portabile ad altri database (B-47).
  • Portabilità. I pragma sono limitati al dialetto sqlite e l'indice del round attivo è dichiarato anche per PostgreSQL. Un DATABASE_URL PostgreSQL non è però mai stato provato, e il database supportato resta SQLite.