Modalità headless (server)

Nota

Questa sezione descrive una modalità avanzata e facoltativa di blunderDB, destinata ai deployment su server, all’uso multiutente e all’automazione. L’uso normale e consigliato di blunderDB resta l’applicazione desktop descritta nei capitoli precedenti. Se usi blunderDB da solo, sul tuo computer, non hai bisogno di questa modalità: puoi ignorare questo capitolo senza perdere nulla delle funzionalità di analisi.

Panoramica

Lo stesso binario blunderdb può, oltre all’applicazione desktop e ai comandi a riga di comando (vedi Interfaccia a riga di comando (CLI)), funzionare in modalità headless: senza interfaccia grafica, pilotato interamente da riga di comando o tramite rete. Questa modalità raggruppa tre usi:

  • il demone serve — espone il motore di blunderDB come servizio HTTP + JSON, per far girare un database condiviso su un server e accedervi in più persone;

  • il dispatcher generico call — richiama qualsiasi operazione di archiviazione direttamente, in locale, per lo scripting e i test;

  • il comando migrate — trasferisce un database SQLite monoutente verso un backend PostgreSQL multiutente.

Questi tre usi si basano su un livello di archiviazione comune capace di dialogare con due backend: SQLite (il consueto formato di file .db dell’applicazione desktop) e PostgreSQL (per i deployment server multiutente).

Il demone serve

blunderdb serve avvia il motore come servizio HTTP che risponde in JSON. Permette di ospitare un database di posizioni su una macchina e di accedervi da più client.

# sqlite
blunderdb serve --db database.db --addr 127.0.0.1:8080

# postgres
blunderdb serve --backend postgres \
    --dsn "postgres://user:pass@host:5432/blunderdb?sslmode=disable" \
    --addr 127.0.0.1:8080

Nota

sslmode=disable è adatto solo a una rete privata fidata — un database in un container vicino, su una rete che non ha una rotta né verso l’host né verso Internet. Per un database remoto, sslmode=require cifra il collegamento e verify-full verifica inoltre il certificato del server e il suo nome host. Le altre stringhe di connessione di questa pagina portano sslmode=disable per lo stesso motivo: descrivono tutte una rete privata.

Avvertimento

Il demone non effettua alcuna autenticazione. Si fida dell’header di richiesta X-Tenant-ID e deve girare dietro un reverse-proxy (nginx, Caddy…) incaricato dell’autenticazione. Non esporlo mai direttamente su Internet pubblico.

X-Tenant-ID è l”intero del tenant (1, 2, 42…): spetta al reverse-proxy far corrispondere l’account autenticato a quell’intero. Un nome (alice) viene rifiutato con 400 invalid, mai convertito.

Opzioni:

Opzione

Predefinito

Significato

--db <percorso>

–

file SQLite (scorciatoia per --backend sqlite --dsn <percorso>)

--backend <tipo>

sqlite

backend di archiviazione: sqlite o postgres

--dsn <stringa>

$BLUNDERDB_DSN

stringa di connessione del backend

--addr <host:porta>

:8080

indirizzo di ascolto

--log-level <livello>

info

livello di logging: debug|info|warn|error

--metrics

true

espone /metrics (formato Prometheus)

--web

false

serve la pagina web di consultazione sotto /app/; spenta per impostazione predefinita, vedi sotto

--direction

false

serve i gesti di direzione di torneo e di evento; disattivati per impostazione predefinita, vedi I gesti di direzione

--mcp-write

false

offre gli strumenti di scrittura di /mcp; disattivati per impostazione predefinita, vedi Strumenti per un assistente IA (MCP)

--transcription

false

serve i gesti di trascrizione (transcriptions.create, apply, finish…); disattivato per impostazione predefinita, vedi Trascrivere tramite l’API

--transcription-ttl <durata>

30m

chiude una sessione di trascrizione inattiva da più tempo

--cors-allow-origin <origine>

–

attiva CORS per questa origine, un elenco di origini separate da virgole, oppure * (disattivato per impostazione predefinita); la risposta riflette l’origine della richiesta solo se compare nell’elenco, con Vary: Origin

--rate-limit-rps <n>

50

limite di richieste al secondo per tenant (0 = disabilitato); abilitato di default con un valore generoso anziché opzionale, affinché un file compose che pensa solo al database non erediti un demone privo di qualsiasi limite

--rate-limit-burst <n>

100

dimensione del secchio di token per i picchi di richieste

--quota-positions <n>

0

posizioni che un tenant può memorizzare, verificate all’inizio di un’importazione: raggiunto il limite, l’importazione viene rifiutata (413, storage_quota_exceeded); positions.save e le altre scritture singole non sono limitate; 0 = illimitato

--quota-analysis-seconds <n>

0

secondi CPU di calcolo del motore per tenant e per giorno UTC (429, quota_exceeded); 0 = illimitato

--quota-imports <n>

0

importazioni di uno stesso tenant in corso contemporaneamente (429, quota_exceeded); 0 = illimitato

--rls

false

PostgreSQL: abilita la Row-Level Security per tenant (difesa in profondità, opzionale)

--read-tenants

false

rispetta l’header X-Read-Tenants nelle letture across.*; se disattivato, viene rifiutato (400) — vedere Leggere più tenant

--bearoff-ts <file>

–

base di bearoff a due lati (.bd) opzionale che amplia la tabella TS-06-06 per l’analisi di corsa del punto di accesso EPC; il demone non scarica mai una base — vedere Le basi di bearoff

--identity-dir <directory>

–

directory dell’identità di firma del demone (creata al primo utilizzo); necessaria affinché exports.sqlite possa apporre una filigrana — vedere più sotto

--ops-addr <host:porta>

–

serve la famiglia /ops/ (maintenance.vacuum, tenant.purge) su un indirizzo separato da --addr, togliendola da quest’ultimo; vuoto (l’impostazione predefinita) le lascia sul listener principale, dove rifiutare il prefisso spetta al proxy — vedi Le rotte di esercizio

--pprof-addr <host:porta>

–

espone net/http/pprof su un indirizzo separato da --addr (disattivato per impostazione predefinita); solo per il debug — questi endpoint non hanno alcuna nozione di tenant e restituiscono un profilo di memoria o CPU dell’intero processo, da non esporre mai pubblicamente né sullo stesso indirizzo di /v1

La maggior parte delle opzioni può essere fornita anche tramite variabile d’ambiente (BLUNDERDB_BACKEND, BLUNDERDB_DSN, BLUNDERDB_ADDR, BLUNDERDB_LOG_LEVEL, BLUNDERDB_METRICS, BLUNDERDB_CORS_ALLOW_ORIGIN, BLUNDERDB_RATE_LIMIT_RPS, BLUNDERDB_RATE_LIMIT_BURST, BLUNDERDB_RLS, BLUNDERDB_READ_TENANTS, BLUNDERDB_TS_PATH, BLUNDERDB_IDENTITY_DIR, BLUNDERDB_OPS_ADDR, BLUNDERDB_PPROF_ADDR): un flag esplicito ha la precedenza sulla variabile corrispondente.

Il demone non ha alcuna opzione di directory dati: scrive le sue tabelle di bearoff in $XDG_DATA_HOME/blunderdb, o in mancanza in ~/.local/share/blunderdb. È dunque XDG_DATA_HOME che le sposta — vedere Le basi di bearoff.

La tabella dei secchi del limitatore di velocità porta essa stessa un tetto rigido (10.000 tenant distinti): oltre tale soglia, ogni nuovo tenant sfratta il secchio meno recentemente utilizzato invece di lasciar crescere la tabella senza limiti — utile se un client invia molti valori X-Tenant-ID distinti, volontariamente o meno, tra due pulizie periodiche dei secchi inattivi.

blunderdb serve ora rifiuta qualsiasi argomento posizionale imprevisto (oltre al solo serve iniziale che un ENTRYPOINT già ridotto al binario nudo lascia passare): senza questo controllo, un flag posto dopo un tale argomento veniva ignorato silenziosamente — docker run image serve --addr :9090, riflesso naturale dato che l”ENTRYPOINT dell’immagine vale già serve, si avviava su :8080 senza una parola.

Punti di accesso

Il servizio espone dei punti di accesso operativi, sempre presenti:

  • GET /healthz — liveness (il processo è in esecuzione);

  • GET /readyz — readiness (l’archiviazione risponde e il suo schema è alla versione attesa);

  • GET /metrics — metriche Prometheus (se --metrics è attivo);

  • GET /app/ — la pagina web di consultazione (se --web è attivo).

La pagina web

blunderdb serve --web serve una pagina sotto /app/: una biblioteca consultabile da un tablet o da un telefono, senza installare nulla.

Sa fare tre cose, e questa lista è la decisione, non una tappa:

  • consultare una posizione, la sua analisi e la sua tavola;

  • cercare, con la stessa grammatica di token della riga di comando dell’applicazione;

  • ripassare un mazzo Anki — risposta svelata e voto dato.

Non sa modificare una posizione, importare, eliminare, gestire raccolte, incontri, tornei o la configurazione, e non lo imparerà. Una funzione che manca qui non è una lacuna: è il perimetro.

È spenta per impostazione predefinita, e quel valore predefinito è la decisione. Il demone non autentica nessuno: si fida dell’intestazione X-Tenant-ID e deve girare dietro un proxy che autentica. Consegnare un’interfaccia raggiungibile da un browser, accesa di serie, inviterebbe esattamente il dispiegamento che quella regola vieta.

La pagina non invia alcun tenant: è il proxy a porre l’intestazione, come per ogni altro client. In sviluppo locale, e solo lì, /app/?tenant=1 ne nomina uno — il che non cambia nulla sulla sicurezza di un demone che già accetta quell’intestazione da chiunque.

I file della pagina sono serviti senza tenant, di proposito: un browser deve poter caricare la pagina prima che il proxy gli assegni qualcosa, e una pagina non contiene dati.

Liveness e readiness rispondono a due domande diverse. /healthz risponde sempre 200 non appena il processo serve richieste, senza mai interrogare l’archiviazione: un orchestratore riavvia il container la cui liveness fallisce, e un database momentaneamente irraggiungibile non deve riavviare in loop un demone sano. /readyz risponde 503 (con status a down o version_mismatch) finché il database non risponde o il suo schema non è quello del binario: il traffico viene semplicemente deviato finché non torna.

Il sottocomando blunderdb healthcheck (presente anche nel binario serve dell’immagine container) esegue una richiesta GET /readyz sul demone locale e restituisce 0 se è pronto, 1 altrimenti; l’indirizzo è quello di --addr o di BLUNDERDB_ADDR, :8080 per impostazione predefinita. È l”HEALTHCHECK dell’immagine Docker, e vale altrettanto in uno script o in un’unità systemd:

blunderdb healthcheck --addr 127.0.0.1:8080 && echo ready

La superficie applicativa segue lo schema POST /v1/<famiglia>.<metodo> (per esempio /v1/positions.save, /v1/matches.get). Le famiglie coprono le posizioni, le analisi, i match, i commenti, le collezioni, i tornei, le carte Anki, i filtri, le sessioni, la cronologia (ricerca e comandi), la ricerca, i metadati, le impostazioni di libreria, le statistiche, l’importazione e l’esportazione. Gli endpoint di elenco restituiscono un flusso NDJSON (un oggetto JSON per riga). Il server si arresta in modo pulito su SIGINT / SIGTERM.

Un errore restituisce la busta {"error":{"code":…,"message":…}}. Il codice not_found dice che una risorsa nominata non esiste; unknown_route, anch’esso 404, dice che il demone non serve il metodo chiamato: un client e un demone di versioni diverse, o una famiglia che il demone serve solo con un’opzione. Un client conclude che un dato manca solo su not_found.

positions.save restituisce {"id":…,"created":…}. created vale true solo per la chiamata che ha inserito la posizione, ed è la scrittura stessa a dirlo: un client che copia una posizione e poi la sua analisi, e deve annullare la copia dopo un errore, elimina la posizione solo se l’ha creata, senza la corsa di un positions.exists preventivo.

Ciò che /v1 promette

Un client scritto contro /v1 deve continuare a funzionare. La regola sta in tre righe, ed è più utile scritta che indovinata:

  • Ciò che esiste non cambia senso. Una rotta di /v1 non è né rinominata, né soppressa, né risignificata. Un campo di richiesta o di risposta non è né rinominato, né tolto, né cambiato di tipo.

  • Ciò che si aggiunge si aggiunge. Una rotta nuova, un campo facoltativo di richiesta, un campo nuovo in una risposta: un client che li ignora continua a funzionare, è la definizione di «compatibile» adottata qui. Un client deve dunque ignorare i campi che non conosce anziché rifiutarli.

  • Il resto è /v2. Rendere obbligatorio un campo che non lo era, cambiare un’unità, cambiare il senso di un codice d’errore: sono rotture, e vivono sotto un altro prefisso, accanto a /v1, il tempo che i client traversino.

Due precisazioni che contano. Le rotte /ops/ non sono coperte: servono all’esercizio di un dispiegamento, cambiano con esso, e non sono un’API per programmi terzi. E il contratto stesso è generato dalla tabella di rotte del demone (openapi.yaml, Contratto API): non può descrivere altro che ciò che il server serve.

Trascrivere tramite l’API

La famiglia transcriptions.* permette a un client esterno di trascrivere un match gesto per gesto, con la stessa logica del desktop. Le letture (list, get, exportMat, losses) sono sempre servite. I gesti (create, open, editMatch, apply, undo, redo, close, finish, abandon) lo sono solo con serve --transcription: senza questo flag, queste rotte rispondono 404.

create e open restituiscono lo stato della bozza, la sua revision e un sessionId. apply, undo, redo, close e finish nominano questo sessionId: assente → 400, sessione scaduta o sconosciuta → 410; il client riapre allora la bozza (open), con il cursore a fine documento. abandon non nomina alcuna sessione: elimina la bozza sotto la sola revisione di If-Match. Ogni gesto che scrive porta l’ultima revisione vista nell’intestazione If-Match e restituisce la successiva:

  • If-Match assente → 428;

  • revisione obsoleta → 409; la busta d’errore indica la revisione corrente (details.revision) e lo stato fresco della bozza (details.state: documento, revisione, sessione e cursore), che il client mostra prima di rieseguire il suo gesto se vale ancora.

La revisione avanza solo quando il documento cambia (intestazione e azioni): spostare il cursore o inserire un dado dell’azione in corso non scrive nulla e restituisce la stessa revisione. Una sessione è quella della bozza, non quella di un client: open restituisce la sessione viva quando ce n’è una, e le schede o le postazioni che la condividono condividono anche il cursore e la pila di annullamento.

La sessione conserva solo la pila di annullamento, il cursore e l’inserimento in corso: la bozza è scritta dopo ogni gesto che la cambia, quindi una sessione persa (inattività, riavvio, altra istanza) non perde alcun gesto. transcriptions.get restituisce la revisione come ETag e risponde 304 a un If-None-Match che la nomina.

finish registra il Match ed elimina la bozza, abandon la elimina senza Match, close libera soltanto la sessione. editMatch apre una bozza su un Match esistente e, per un match importato, restituisce il conteggio delle analisi e dei commenti che la trascrizione non conserva (losses.lossy). L’analisi del match salvato si avvia con gammonnet.analyzeMissing.

Avvertimento

Il demone non autentica nessuno: aprire la scrittura significa affidarla al proxy (Distribuzione dietro un proxy autenticante). Un ruolo «trascrittore» è una regola del proxy sul prefisso /v1/transcriptions., non una nozione del demone.

Un client Python

clients/python/ contiene un client minimo, senza dipendenze fuori dalla libreria standard — il demone parla POST e JSON, che urllib e json coprono interamente:

from blunderdb import Client

api = Client("http://127.0.0.1:8080", tenant=1)
print(api.metadata_counts())

for position in api.positions_list({"limit": 10}):
    print(position["id"])

È in due metà, ed è voluto. _generated.py porta un metodo per rotta, generato dalla tabella di rotte del demone con go run ./cmd/openapi-gen: una superficie scritta a mano devierebbe il giorno in cui una rotta è aggiunta, e nessuno se ne accorgerebbe prima di un utente. client.py porta il trasporto — la sessione, l’intestazione di tenant, la busta d’errore, la lettura dell’NDJSON — ed è scritto a mano. Ciò che cambia con l’API è generato; ciò che cambia con il giudizio no.

I nomi di metodo sono famiglia_operazione in snake_case: /v1/positions.loadByIds diventa positions_load_by_ids(). La famiglia è conservata perché più famiglie condividono un nome di operazione (list, delete), e un list() nudo entrerebbe in collisione.

events() segue /v1/events e restituisce un dizionario per messaggio (vedi Essere avvisati dei gesti: /v1/events).

Un fallimento solleva APIError, che porta la busta del demone tale quale: il code (ciò su cui un programma si dirama), il message (ciò che una persona legge), lo stato HTTP e i dettagli.

Incorporare il motore in un programma Go

pkg/blunderdb/server.Bootstrap apre lo storage e restituisce un insieme di gestori nel processo chiamante, senza ascoltare su una porta. È la porta d’ingresso di un genitore fidato — gammonGo — che vuole la biblioteca di posizioni senza far girare un demone accanto né parlare HTTP con sé stesso.

Ciò che questo presuppone è esplicito: il genitore è fidato. Non c’è tenant da verificare, né intestazione da validare, né limitatore di frequenza — queste cose appartengono al demone perché affronta una rete, e l’ADR-0005 dice perché. Un programma che incorpora il motore sceglie da sé il proprio tenant e risponde delle proprie chiamate.

Direzione dei tornei ed eventi

I tornei diretti alla postazione e gli eventi che li raggruppano (rencontre nell’API e nelle sue rotte /v1/rencontres.*) si leggono tramite l’API, sotto il tenant del chiamante, con lo stesso codice della postazione. La lettura è sempre servita; i gesti (inserire un risultato, abbinare, creare un evento) lo sono solo con serve --direction (I gesti di direzione).

  • directions.list e directions.directory leggono l’intero tenant: l’elenco dei tornei diretti, la rubrica dei giocatori.

  • Le altre directions.* accettano {"tournamentId": N}: directions.get (la vista completa: proposte, classifica, partite in corso), directions.participants, directions.freeParticipants, directions.tableGrid, directions.brackets, directions.standings, directions.standingsCsv, directions.history (filtri facoltativi player e match), directions.clock, directions.slots, directions.lastDecision, directions.pageHtml e directions.pairingSheetHtml (con round).

  • rencontres.list, poi rencontres.get e rencontres.pageHtml con {"id": N}. rencontres.pageHtml produce la pagina murale della sala, un documento HTML autonomo nel campo html: uno schermo murale la visualizza e la rilegge periodicamente.

  • rencontres.ranking restituisce la classifica di stagione, come blunderdb tournament ranking --season: rencontreId, from, to, points, participation ed elo, tutti facoltativi; senza rencontreId né periodo, contano tutti i tornei diretti del tenant.

Le pagine sono prodotte in francese, la lingua del motore di direzione. Un torneo che non è diretto, o che appartiene a un altro tenant, risponde 404.

Letture condizionali. Ciascuna di queste rotte restituisce un’intestazione ETag. Rinviata in If-None-Match, ottiene 304 senza corpo finché non è cambiato nulla di ciò che la rotta legge. Ogni scrittura cambia subito l”ETag: un gesto nel torneo o in un torneo dello stesso evento, il collegamento di una partita, una bozza avviata da uno slot, la ridenominazione di un torneo, la modifica dell’evento. Rispondere 304 non rigioca alcun torneo, il che rende poco costosa una pagina murale che interroga ogni pochi secondi. Fa eccezione solo ciò che dipende dall’ora: le proposte, l’orologio e le pagine sono calcolate al momento della lettura, e un ETag vale quindi al massimo un minuto. Un client che rilegge vede così passare una scadenza o una pausa entro il minuto.

Queste rotte sono POST. Per questo verbo, la RFC 9110 (§13.1.2) risponde 412 a un If-None-Match verificato. Il demone risponde tuttavia 304: il corpo della richiesta contiene solo i parametri di una lettura senza effetti, che si comporta come un GET. La forma If-None-Match: * è rifiutata (400), perché non designa alcuna risposta che il client avrebbe già. Una richiesta non valida (un round negativo, per esempio) è rifiutata prima di qualsiasi condizione.

curl -si -X POST http://127.0.0.1:8080/v1/rencontres.pageHtml \
  -H 'X-Tenant-ID: 1' -d '{"id":1}' | grep -i '^etag'
curl -si -X POST http://127.0.0.1:8080/v1/rencontres.pageHtml \
  -H 'X-Tenant-ID: 1' -H 'If-None-Match: W/"…"' -d '{"id":1}'
# HTTP/1.1 304 Not Modified

Come il resto di /v1, queste rotte non autenticano nessuno: dietro il proxy (Distribuzione dietro un proxy autenticante), chiunque raggiunga il prefisso /v1/directions. di un tenant ne legge i tornei, nomi dei giocatori compresi. Un proxy che riserva queste letture a certi utenti lo fa con una regola su questo prefisso e su /v1/rencontres..

I gesti di direzione

blunderdb serve --direction apre i gesti che la postazione compie su un torneo diretto e su un evento. Senza questo flag, queste route rispondono 404, come se non esistessero. call le serve sempre.

  • directions.create (tournamentId, config, seed), directions.setConfig e directions.previewConfig (config, la configurazione nel formato JSON del motore);

  • le iscrizioni: directions.enterParticipants (players), directions.addParticipant (name, club, rating; con section e key, un ritardatario prende un posto di esenzione), directions.updateParticipant, directions.withdraw, directions.reinstate, directions.makeAbsent, directions.makeAvailable, directions.addPair, directions.updatePair;

  • lo svolgimento: directions.confirmProposal (action, come proposta da directions.get), directions.confirmAllProposals, directions.startMatch, directions.enterResult, directions.enterForfeit, directions.moveMatchToTable, directions.cancelMatch, directions.correctResult, directions.close, directions.reopen, directions.addNote, directions.attachMatch, directions.detachMatch;

  • l’evento: rencontres.create, rencontres.update, rencontres.attach, rencontres.detach, rencontres.trash, rencontres.setTableOutOfService, rencontres.setBreaks;

  • le proprietà dei tavoli: rencontres.setTables (id, tableSettings, una voce per ogni tavolo che ne ha: numero, nome, sala, riservato, assegnato a), rencontres.setEventRooms (id, tournamentId, rooms, le sale in cui gioca la prova; nessuna significa tutti i tavoli) e directions.setTables (tournamentId, tableSettings) per una prova che gioca da sola.

Un gesto di torneo restituisce la vista completa del torneo, come directions.get; un gesto di evento restituisce l’evento. Il servizio riscrive poi le pagine di visualizzazione nella cartella indicata dal database, come sulla postazione di lavoro. Una pagina che non si può scrivere (cartella scomparsa, disco pieno) non annulla il gesto: la risposta porta un’intestazione Direction-Page-Warning per ogni pagina non scritta (tournament 3, rencontre 2), senza il percorso del server, e la postazione di lavoro la mostra nella barra di stato.

Un gesto che le regole rifiutano (nome vuoto, tavolo occupato, torneo non ancora iniziato, configurazione rifiutata dal motore) restituisce 400 con il motivo. Un guasto del demone o del suo database restituisce 500, senza dettagli: il motivo resta nel registro del demone.

Versione obbligatoria. Ogni lettura di un torneo o di un evento restituisce un’intestazione Direction-Version, e ogni gesto la rinvia in If-Match:

  • senza If-Match (o con *), il gesto è rifiutato: 428;

  • se qualcuno ha scritto dopo quella lettura, il gesto è rifiutato: 409. Il campo details dell’errore riporta lo stato aggiornato e la sua version: il client rilegge, poi rinvia il gesto se è ancora valido;

  • altrimenti il gesto viene applicato e restituisce la nuova versione in Direction-Version.

Il confronto avviene nella transazione del gesto, sotto un blocco del database (blocco consultivo PostgreSQL per torneo o per evento, blocco di scrittura SQLite): di due gesti inviati sulla stessa lettura, solo uno viene applicato, sia che passino da uno stesso demone, da due demoni su uno stesso database PostgreSQL, o dalla postazione di lavoro e da call su uno stesso file. Il gesto viene scritto per intero o per nulla. Un torneo giocato in un evento ha la versione del suo evento, per cui un gesto in una prova sorella la cambia a sua volta. directions.create e rencontres.create non puntano a nulla di esistente e non prendono una versione.

Idempotenza. Un gesto che porta un’intestazione Idempotency-Key viene applicato una sola volta: rinviato con la stessa chiave, restituisce la prima risposta, con le sue intestazioni (Direction-Version compresa) e Idempotency-Replayed: true. Un doppio clic o un nuovo tentativo di rete non registra due risultati; due invii simultanei della stessa chiave eseguono il gesto una sola volta. Solo una risposta riuscita viene conservata.

  • La chiave è legata al corpo della richiesta: la stessa chiave con un altro corpo restituisce 422.

  • La riproduzione precede il controllo di versione: restituisce la risposta conservata senza 428 né 409, anche se la versione è cambiata nel frattempo.

  • Le chiavi vivono in memoria, in ogni istanza del demone, per 24 ore, al massimo 1 000 per tenant: un riavvio le dimentica, e un’altra istanza non le conosce.

curl -si -X POST http://127.0.0.1:8080/v1/directions.get \
  -H 'X-Tenant-ID: 1' -d '{"tournamentId":3}' | grep -i '^direction-version'
curl -s -X POST http://127.0.0.1:8080/v1/directions.enterResult \
  -H 'X-Tenant-ID: 1' -H 'If-Match: "…"' -H 'Idempotency-Key: t4-r2' \
  -d '{"tournamentId":3,"matchId":"m7","winner":"aa","scoreA":7,"scoreB":3}'

Avvertimento

Il demone non autentica nessuno (ADR-0005). Con --direction, chiunque il proxy lasci passare inserisce risultati. Il motore non conosce alcun ruolo (direttore, arbitro, lettore): un ruolo è una regola del proxy, che riserva /v1/directions. e /v1/rencontres. ai direttori, o lascia passare solo le letture. Non avviate mai --direction su un demone raggiungibile senza questo proxy, nemmeno sul Wi-Fi di un club.

Essere avvisati dei gesti: /v1/events

GET /v1/events è un flusso Server-Sent Events (text/event-stream): un messaggio per ogni gesto convalidato del tenant, pubblicato dopo la scrittura nel database, mai per un gesto rifiutato o annullato. Il messaggio dice che cosa è cambiato e la sua nuova versione, non lo stato: il client rilegge ciò che mostra, con If-None-Match.

  • event: rencontre — rencontreId, tournamentIds (le prove dell’evento, prima e dopo il gesto) e version;

  • event: direction — tournamentId e version, per un torneo giocato al di fuori di qualsiasi evento;

  • event: transcription — transcriptionId e revision; una bozza abbandonata o terminata porta removed (e matchId per Terminare).

removed: true segnala ciò che non esiste più. La route è servita solo con --direction o --transcription: senza di essi, il demone non scrive nulla che dovrebbe annunciare, e /v1/events risponde 404. Come ogni route /v1/, richiede X-Tenant-ID: un abbonato sente solo il proprio tenant. Un tenant tiene al massimo 16 flussi aperti alla volta; oltre, 429. La postazione usa lo stesso servizio ma non vi collega alcun bus: i suoi gesti non sono annunciati.

I parametri tournament, rencontre e transcription (identificatori separati da virgole, o ripetuti) restringono l’abbonamento: un messaggio passa se ne nomina uno. Un torneo di un evento riceve i messaggi del suo evento. Un parametro sconosciuto o un identificatore non valido restituisce 400.

curl -N http://127.0.0.1:8080/v1/events?rencontre=2 -H 'X-Tenant-ID: 1'

Nessuna cronologia. Il demone non conserva alcun messaggio. Ogni flusso si apre con event: resync, con un id: il client può aver perso dei gesti prima di connettersi, o tra due connessioni, e rilegge tutto ciò che mostra. Il motivo è reconnected quando la richiesta porta Last-Event-ID, subscribed altrimenti. Un abbonato troppo lento, la cui coda di 64 messaggi è piena, viene disconnesso dopo lo stesso resync: non ritarda mai un gesto. Il flusso annuncia un ritardo di riconnessione di 3 secondi.

Attraverso un proxy. Un commento : ping viene inviato ogni 25 secondi affinché un proxy non interrompa un flusso silenzioso; X-Accel-Buffering: no chiede a nginx di non metterlo in buffer. Il flusso non è compresso, sfugge al timeout delle richieste ordinarie e conta come una sola richiesta per la limitazione della frequenza. L’arresto del demone chiude tutti i flussi; una sottoscrizione richiesta durante l’arresto riceve 503.

Più istanze. Su SQLite, una sola istanza detiene il database: il bus in memoria è sufficiente. Su PostgreSQL, non appena --direction o --transcription è attivo, ogni istanza ritrasmette le proprie azioni alle altre tramite LISTEN/NOTIFY, sul canale blunderdb_events: un abbonato collegato a un’istanza sente un’azione convalidata su un’altra, o eseguita tramite call sullo stesso database. Il tenant viaggia nella notifica, e l’istanza che la riceve la consegna solo agli abbonati di quel tenant. Ogni istanza apre due connessioni in più (application_name blunderdb-events-… per l’ascolto, blunderdb-notify-… per l’invio); un’istanza che all’avvio non può ascoltare rifiuta di avviarsi. call annuncia senza ascoltare, e serve la propria richiesta anche se non può annunciare.

Qualsiasi ruolo autorizzato a connettersi può emettere su questo canale, anche con --rls. Una notifica ricevuta è creduta solo se il suo tenant è valido e il suo tipo noto; il resto viene registrato nel log e ignorato. Una notifica contraffatta può al peggio far rileggere i propri dati agli abbonati di un tenant.

  • La notifica parte dopo la scrittura nel database, come il messaggio locale. Restano due perdite senza resync: un’istanza terminata tra la scrittura e la notifica, e un arresto che non può inviare in 2 secondi ciò che resta in coda. L’azione è convalidata, ma i flussi già aperti sulle altre istanze la apprendono solo alla riconnessione del loro client.

  • Una connessione di ascolto persa viene ristabilita, con un’attesa crescente da 250 ms a 30 s. Le azioni delle altre istanze avvenute durante l’interruzione vanno perse: alla ripresa, ogni abbonato dell’istanza riceve un resync con il motivo missed. Una notifica troppo lunga per PostgreSQL (8 000 byte), o che un’istanza non ha potuto inviare, arriva alle altre come lo stesso resync per il tenant interessato.

  • Gli id del flusso sono propri di ciascuna istanza. Un client che un bilanciatore di carico invia a un’altra istanza non ne ricava nulla: il resync che apre ogni flusso gli fa rileggere ciò che visualizza.

Le basi di bearoff

Il demone calcola le sue due tabelle predefinite all’avvio, in secondo piano (TS-06-06 per il verdetto di cubo, OS-06 per l’EPC): circa sei secondi di un core, una volta, nella sua directory dati — $XDG_DATA_HOME/blunderdb, o in mancanza ~/.local/share/blunderdb. Nulla viene scaricato e nulla è incorporato nel binario (ADR-0027). Se questa cartella è in sola lettura, le tabelle sono tenute in memoria per la durata del processo: il servizio si avvia, paga semplicemente il calcolo a ogni riavvio.

Un dominio più ampio non si calcola all’avvio — TS-06-11 pesa 1,2 GB e richiede minuti, non è qualcosa che un servizio decide da solo. Spetta all’operatore fabbricarlo, con la CLI, nel volume che il demone leggerà:

# generate
blunderdb bearoff generate --ts 6x11 --data-dir /srv/data/blunderdb

# serve
XDG_DATA_HOME=/srv/data blunderdb serve --db database.db
blunderdb serve --db database.db \
    --bearoff-ts /srv/data/blunderdb/gnubg_ts6x11.bd

Il primo avvio lascia che il demone trovi la tabella da solo nella sua directory dati; il secondo la designa tramite il suo percorso, ovunque essa sia. --data-dir è un’opzione dei sottocomandi bearoff, mai di serve.

blunderdb bearoff list --data-dir /srv/data/blunderdb dice cosa contiene il volume e quanto costerebbe ogni dominio; blunderdb bearoff verify esce con errore su una tabella corrotta, il che ne fa una sonda di avvio utilizzabile così com’è. Vedere Interfaccia a riga di comando (CLI) per il dettaglio.

Le rotte di esercizio

Due chiamate non si fermano al tenant che le effettua e vivono quindi sotto un prefisso proprio, POST /ops/<famiglia>.<metodo>:

  • /ops/maintenance.vacuum (backend SQLite) riscrive l”intero file, dati di tutti i tenant compresi, e tiene un lock di scrittura per tutta la durata;

  • /ops/tenant.purge (backend PostgreSQL) distrugge i dati di un tenant, e il tenant distrutto è quello nominato dall’intestazione che il chiamante controlla.

Il demone non autentica nessuno (vedi sotto): una rotta raggiungibile da un tenant è una rotta che ogni tenant può chiamare. Il prefisso esiste perché il proxy possa rifiutarle entrambe con una sola regola. Non esporre mai /ops/ attraverso il proxy pubblico. Sotto nginx, la regola sta in una riga del blocco server; sotto Caddy, in due righe del sito:

location /ops/    { return 403; }
location /metrics { return 403; }
@closed path /ops/* /metrics
respond @closed 403

L’opzione --ops-addr <host:porta> va oltre: le due rotte lasciano allora l’indirizzo --addr e sono servite solo su quel secondo listener, da associare a un’interfaccia di amministrazione. Senza l’opzione restano sul listener principale e bloccarle spetta al proxy.

Queste rotte richiedono l’intestazione X-Tenant-ID come tutte le altre — una purga nomina il tenant che distrugge e ne ha bisogno più di chiunque. Solo le sonde (/healthz, /readyz) e /metrics ne fanno a meno.

Ecco perché la regola di rifiuto qui sopra copre anche /metrics: non richiedendo alcun tenant, è leggibile da chiunque raggiunga il demone, e pubblica la dimensione del database e il lavoro in corso, tutti i tenant confusi insieme. Si consulta dalla macchina del demone, o tramite un percorso che il proxy riserva all’esercizio. Il terzo punto da non esporre mai non è una rotta ma un listener: quello di --pprof-addr, che non ha alcuna nozione di tenant e fornisce un profilo dell’intero processo. Si lega a un’interfaccia di amministrazione, mai pubblicato dal proxy.

Ciò che non è passato sotto /ops/: /v1/gammonnet.sweepStale. Il recupero è costoso ma è circoscritto al tenant chiamante; a limitarlo sono il rate limit e gli indicatori di lavoro in corso, non un confine di fiducia.

Il contratto completo — ogni metodo, la sua richiesta e la sua risposta — è generato dal sorgente e versionato: openapi.yaml nella radice del repository (formato OpenAPI, schemi compresi) e il suo allegato leggibile, Contratto API (una tabella per famiglia). Entrambi vengono rigenerati da go run ./cmd/openapi-gen e un test dedicato fallisce se uno dei due resta indietro rispetto alle rotte effettivamente registrate.

Ogni richiesta /v1 accetta un corpo JSON (Content-Type: application/json, oppure nessuna intestazione — un corpo di altro tipo viene rifiutato con 400 invalid invece di fallire con un messaggio di parsing JSON confuso); un metodo noto chiamato con il verbo HTTP sbagliato risponde 405, con l’intestazione Allow che indica l’unico verbo accettato. I metodi di elenco che accettano un limit rifiutano oltre 1000 righe per pagina (400 invalid) invece di onorare un valore senza limite.

Ogni famiglia che elenca accetta limit e offset: positions.list, positions.listIds, matches.list, search.find, anki.reviewLog, comments.listAll, tournaments.list e collections.positions. Entrambi valgono zero per impostazione predefinita, il che significa ciò che ha sempre significato: tutto. Non c’è alcun tetto implicito — un flusso non è tenuto in memoria, quindi un elenco senza limite costa tempo e banda ma mai l’equilibrio del demone, mentre un limite predefinito silenzioso farebbe leggere a un client un elenco troncato credendolo completo. Ciò che i due parametri offrono è la possibilità di paginare, a chi lo vuole.

Ogni connessione TCP è limitata in lettura/scrittura per richiesta — un margine generoso per le chiamate ordinarie, molto più ampio per le rotte che trasmettono in streaming (elenchi NDJSON, importazioni/esportazioni, la scansione di recupero di gammonNet) — e il loro numero simultaneo è limitato: oltre tale soglia, una connessione ulteriore attende che una di quelle esistenti si liberi invece di ricevere incondizionatamente un proprio thread di esecuzione. Un arresto controllato (SIGINT/SIGTERM) annulla prima ogni importazione e ogni scansione di recupero gammonNet in corso — ciascuna risponde con un evento finale {"event":"cancelled"} invece di vedere la propria connessione interrotta senza spiegazioni — prima di chiudere il server entro il consueto periodo di grazia. Il file temporaneo di un’importazione caricata mantiene dell’estensione originale solo quelle riconosciute dal demone (.xg, .xgp, .sgf, .mat, .bgf, .ogxm, .txt, .db, .dbx), e tutte le importazioni simultanee — di tutti i tenant — condividono una quota globale di byte depositati su disco: oltre tale quota, una nuova importazione viene rifiutata (too many requests) invece di lasciare crescere senza limiti l’occupazione di $TMPDIR.

/v1/imports.json rilegge un’esportazione JSON di blunderDB colmando i vuoti: l’analisi che contiene si scrive solo su una posizione che non ne ha ancora, senza mai sostituire un’analisi esistente, e i rollout di entrambe le parti vengono conservati.

La famiglia search offre tre porte sulla stessa ricerca. search.find prende l’oggetto dei filtri completo, campo per campo. search.query prende una query scritta nel linguaggio della barra dei comandi dell’applicazione (s cube p>30 E>50, descritto in Elenco dei comandi) e trasmette le stesse posizioni; è l’unico modo di raggiungere, via rete, i filtri che non hanno un campo evidente — schema di mossa, testo del commento, giocatore, data, dadi esclusi, zone e blot. search.parse non cerca nulla: risponde che cosa significa una query — i filtri che denota, la sua forma canonica (due query equivalenti la condividono, il che rende confrontabile una ricerca salvata) e le sue diagnostiche.

Una query che porta un token che nulla riconosce viene rifiutata (400 invalid, con il nome del token) invece di essere eseguita restringendo la ricerca in silenzio. Un token compreso ma senza effetto qui — x, che attiva la struttura di esclusione, la quale è un tavoliere e non del testo — viaggia nell’intestazione X-BlunderDB-Query-Diagnostics, così che il corpo resti NDJSON di posizioni per tutti i client esistenti.

Due metodi della famiglia positions decodificano una posizione senza salvarla: positions.fromXGID ricostruisce una posizione da una stringa XGID e positions.fromXGP da un file di posizione singola .xgp.

POST /v1/exports.sqlite esporta l’intero tenant corrente — posizioni, collezioni, match, tornei, analisi, commenti, mosse giocate, libreria di filtri e mazzi Anki — in un file SQLite apribile così com’è dal desktop. Il corpo JSON della richiesta è opzionale: watermarkOrigin / watermarkNote appongono una filigrana firmata con l’identità propria del demone (--identity-dir) — senza questi campi, l’esportazione non porta alcuna filigrana; richiederli senza un’identità configurata fallisce con il codice invalid. collectionIds limita l’esportazione a quelle collezioni e alle loro posizioni, con analisi, commenti e mosse giocate, senza la libreria di filtri né i mazzi Anki.

Condividere una collezione tra tenant passa dal client, mai da una lettura di un tenant nell’altro: il tenant che dà chiama exports.sqlite con collectionIds (e una filigrana, perché chi riceve sappia da dove viene il file), il tenant che riceve invia il file a imports.db. Ogni richiesta porta il proprio X-Tenant-ID; il proxy decide chi ha il diritto di fare l’una e l’altra. All’importazione, una collezione si unisce a quella con lo stesso nome del destinatario, oppure viene creata; le sue posizioni vi si aggiungono in coda, senza duplicati. Una collezione viva del destinatario non riceve alcuna posizione: è la sua query a definirne il contenuto. L’importazione di un database nell’applicazione desktop segue la stessa regola.

curl -X POST http://127.0.0.1:8080/v1/exports.sqlite \
     -H 'X-Tenant-ID: club-lyon' -H 'Content-Type: application/json' \
     -d '{"collectionIds":[4],"watermarkOrigin":"Club de Lyon"}' -o ouvertures.db
curl -X POST http://127.0.0.1:8080/v1/imports.db \
     -H 'X-Tenant-ID: alice' -F file=@ouvertures.db

La famiglia training tiene il registro della scheda Allenamento: training.save aggiunge una sessione (exercise, seedSource, conteggi, items) e restituisce il suo id (Idempotency-Key accettato); training.sessions rilegge le sessioni, la più recente per prima (exercise e limit facoltativi); training.numberStats aggrega gli elementi di un esercizio per tipo di numero. Le domande, invece, sono estratte dal client.

gammonnet.evaluate valuta una posizione nuda (position o xgid), senza leggere né scrivere nulla nel tenant: con i dadi, le mosse migliori (candidates, 5 per impostazione predefinita, al massimo 20); senza dadi, la decisione di cubo. ply va da 0 a 2 (2 per impostazione predefinita); una ricerca più profonda è compito di analyzeMissing.

La famiglia anki guadagna sei metodi che estendono il pianificatore a ripetizione dilazionata (FSRS): anki.reviewLog (registro di ogni revisione — valutazione e risultato FSRS — per le statistiche di ritenzione e uno storico fedele), anki.forecast (proiezione del numero di carte in scadenza nei prossimi giorni, incluse quelle in ritardo), anki.suspendCard / anki.buryCard / anki.removeCard (rimuovere una carta dalla coda di revisione temporaneamente o definitivamente) e anki.retention (il tasso di successo misurato sulle revisioni di un mazzo, letto rispetto all’obiettivo fissato dal suo proprietario).

Nota

anki.retention sostituisce anki.optimizeParams, che spostava l’obiettivo verso il tasso osservato e poteva scriverlo. L’obiettivo di ritenzione è una scelta sul compromesso tra carico e qualità, il tasso misurato ne è il risultato, e agganciare l’uno all’altro è esattamente il meccanismo che gli autori di FSRS respingono. Il metodo si limita a misurare, senza mai scrivere.

La famiglia stats fornisce stats.playerTable, che restituisce una riga di statistiche per giocatore (match, vittorie/sconfitte, decisioni conteggiate, PR globale / pedine / cubo, Snowie Error Rate, errori, blunder e fortuna) sui match trattenuti dal filtro trasmesso. Come nell’interfaccia grafica, questa tabella onora del filtro solo il periodo, i tornei e la lunghezza dei match: la selezione di un giocatore e il tipo di decisione sono ignorati, poiché la tabella riguarda tutti i giocatori e ripartisce già pedine e cubo in colonne distinte. Il campo luck_known indica se la fortuna è stata misurata per quel giocatore; luck_rate_mp non va letto quando vale false, perché una fortuna sconosciuta non è una fortuna nulla.

Il filtro trasmesso ai metodi stats accetta, accanto a PlayerName, un campo PlayerAliases: le altre grafie con cui la stessa persona ha firmato. Poiché il nome di un giocatore viene digitato a mano in ogni file, una stessa persona compare spesso sotto più grafie, e un filtro che ne trattiene una sola calcola su una parte dei match senza che nulla sembri anomalo. Il campo è puramente additivo: vengono trattenute le decisioni di uno qualsiasi dei nomi. Fondere i nomi nel database (MergePlayers) è l’altra risposta, da riservare ai database non ricevuti da qualcun altro — riscrive i match di tutti.

Due metodi completano la parità con l’interfaccia grafica: stats.tournamentBadges restituisce, per ogni torneo del database, l’indicatore mostrato sulla sua scheda (PR del giocatore di riferimento), e matches.findByHash indica se un dato match è già presente, a partire dalle due impronte di rilevamento dei duplicati — quanto basta per evitare un’importazione ridondante prima di avviarla.

Il campo winner di una partita, ricevuto da matches.createGame e restituito da matches.games, ha una sola codifica: 1 per il giocatore 1, -1 per il giocatore 2, 0 per una partita non terminata. Un client che invia ancora 0, 1 o -1 nel senso di gnubg (0 per il giocatore 1, 1 per il giocatore 2) registra il vincitore opposto.

analyses.repair ricalcola le colonne denormalizzate di un’analisi (fra cui cube_error) a partire dalla sua analisi completa, e restituisce il numero di righe realmente corrette. Quelle colonne sono soltanto una proiezione: un errore di proiezione si ripara dunque senza reimportare i file di origine. L’operazione è esplicita e non parte mai da sola — né all’apertura di un database né per effetto di una migrazione, dato che lo schema non è in causa. Un’analisi illeggibile viene lasciata com’è anziché azzerata. Il caso noto: i non-doppi etichettati «Double No» da gnuBG, letti male prima della versione 0.33.0, che portavano l’errore di un doppio mai avvenuto.

gammonnet.analyzeMissing avvia il recupero gammonNet del tenant corrente: scrivere un’analisi per ogni posizione che non ne ha alcuna (ADR-0013, ADR-0015). È un’operazione di libreria — legge e scrive posizioni e analisi memorizzate — mai un valutatore nudo: blunderdb serve opera su una libreria, gammonnet serve valuta una posizione. La risposta è un flusso NDJSON (started, progress, poi done oppure error/cancelled), sullo stesso modello dei punti di accesso di importazione; gammonnet.analyzeMissing.cancel (con il job_id ricevuto nell’evento started) annulla un recupero in corso e serve indifferentemente per un recupero o una rianalisi (più sotto). È la stessa operazione dell’avvio automatico dopo l’importazione e del gesto esplicito dell’interfaccia grafica, e del sottocomando blunderdb analyze (vedere Interfaccia a riga di comando (CLI)) — tre forme, una sola logica.

gammonnet.sweepStale è la controparte di analyzeMissing per la rianalisi anziché il riempimento delle lacune: ogni posizione la cui analisi è interamente di gammonNet ma obsoleta — una versione del motore più vecchia di quella in esecuzione, o una profondità diversa da ply — viene rivalutata alla profondità richiesta. Il predicato di obsolescenza è condiviso con lo stesso lotto dell’interfaccia grafica e di blunderdb analyze --stale (nessuna logica duplicata tra le tre modalità); una posizione che porta un’analisi XG, GNUbg o BGBlitz non viene mai toccata, qualunque sia il suo contenuto gammonNet — la protezione di ADR-0013 resta incondizionata. Stessa forma NDJSON di analyzeMissing, e l’evento finale di ciascuna delle due rotte porta la ripartizione evaluated/refused/failed: una posizione che gammonNet rifiuta di valutare (un punteggio di partita fuori dalla portata della sua tabella, una decisione di raddoppio che il modello rifiuta) conta come refused, non failed — non viene mai ritentata invano al passaggio successivo, a differenza di una posizione realmente fallita.

rollout.position gioca una posizione della libreria (positionId) con un rollout e restituisce, per ogni candidato, l’equity, il suo intervallo al 95 % e la JSD; rollout porta le impostazioni (fast, standard o standard,ply=1…), store registra il rollout terminato come una seconda analisi, accanto a quella che porta la posizione, che non sostituisce mai. Una posizione nuda (un XGID) viene rifiutata: il demone opera su una libreria. rollout.filter è la forma in blocco di blunderdb analyze --rollout: le posizioni scelte da query (il linguaggio della ricerca) che non portano ancora un rollout con le stesse impostazioni vengono giocate una dopo l’altra e registrate man mano, in un flusso NDJSON (started, progress dopo ogni serie di partite, poi done, cancelled o quota_exceeded); rollout.filter.cancel lo annulla con il suo job_id. Un tenant esegue un solo blocco alla volta, rollout o gammonNet. rollout.list legge i rollout registrati di una posizione.

Correlazione e metriche di business

Ogni richiesta riceve un identificatore di correlazione: quello che il client (o un reverse proxy) invia nell’intestazione X-Request-Id, altrimenti uno generato — in entrambi i casi restituito sulla stessa intestazione della risposta e aggiunto alla riga di log che chiude la richiesta (campo request_id). Un eventuale traceparent (W3C Trace Context) viene riportato tale e quale in quella stessa riga di log — il demone non lo analizza né lo convalida e non incorpora alcuna libreria di tracciamento: è un ponte per correlare questi log con una catena di tracciamento a monte, nulla di più.

Oltre al volume delle richieste e alla loro latenza, /metrics pubblica indicatori sul lavoro in corso, altrimenti invisibile per un’importazione o un lotto gammonNet bloccati (una sola richiesta molto lunga, non molte richieste):

  • blunderdb_imports_inflight — importazioni in corso, su tutti i tenant;

  • blunderdb_import_spool_bytes — byte attualmente riservati sulla quota di spool d’importazione (vedi --rate-limit-* sopra per il corrispettivo in richieste al secondo);

  • blunderdb_gammonnet_sweep_inflight — recuperi gammonNet in corso, su tutti i tenant;

  • blunderdb_database_size_bytes — dimensione del file SQLite principale, o pg_database_size con PostgreSQL (l’intero database, non per tenant, come gli indicatori del pool di connessioni qui sotto); assente finché non è stata pubblicata alcuna misura.

Un profilo di memoria o CPU del processo è ottenibile avviando con --pprof-addr <host:porta> (net/http/pprof): disattivato per impostazione predefinita e volutamente su un indirizzo separato da --addr, dato che questi endpoint non hanno alcuna nozione di tenant.

Compressione dei flussi

Gli elenchi NDJSON ripetono gli stessi nomi di campo a ogni riga. Il demone li comprime quando il client lo accetta: inviate Accept-Encoding: gzip e la risposta torna in Content-Encoding: gzip. Misurato su un elenco di match: 13,5 % della dimensione originale su mille righe, 14,6 % su cento.

La compressione non cambia nulla al carattere incrementale del flusso: ogni record viene inviato al client come prima, solo compresso lungo il cammino. Si applica solo alle risposte NDJSON, JSON e testo: un’esportazione di database o un contenitore .dbx è già compresso, e ricomprimerlo lo farebbe solo crescere. Accept-Encoding: gzip;q=0 la rifiuta esplicitamente.

Un solo tenant su SQLite

Il backend SQLite non ha una colonna di tenant: tutti i dati stanno nelle stesse tabelle, senza separazione. Su quel backend il demone rifiuta quindi qualsiasi X-Tenant-ID diverso da 1 — accettare gli altri significherebbe servire a ciascuno le righe di tutti dietro un’intestazione che sostiene il contrario. Un deployment che ha davvero più tenant ha bisogno del backend PostgreSQL.

Leggere più tenant

Un allenatore che legge i match dei suoi allievi, un club che condivide una libreria: la relazione tra questi account risiede nell’host che li autentica, mai nel demone. Il proxy la esprime con l’header X-Read-Tenants, un elenco di tenant separati da virgole (X-Read-Tenants: 2, 3), che imposta accanto a X-Tenant-ID. Il demone si fida di esso come di X-Tenant-ID e non autorizza nulla da sé (ADR-0063).

La funzione è disattivata per impostazione predefinita, e disattivata significa rifiutata: finché il demone non viene avviato con --read-tenants (o BLUNDERDB_READ_TENANTS=true; Config.TrustReadTenants per un host che incorpora il motore), qualsiasi richiesta che porti un X-Read-Tenants non vuoto è rifiutata (400), qualunque sia la route. Attivarla solo dopo aver configurato il proxy in modo che rimuova ogni valore inviato dal client e imposti lui stesso l’elenco.

Solo le letture /v1/across.* guardano questo header. L’elenco completo: across.searchFind, across.matchesList, across.statsCompute e across.playerTable; leggono prima X-Tenant-ID, poi ogni tenant elencato nell’ordine dell’header, al massimo 64 tenant distinti in tutto. Su un tenant dell’elenco, nominato con l’id: across.matchesGet, across.matchMovePositions (le posizioni di un match, mossa per mossa) e across.analysesLoadByIds; un tenant assente dall’elenco vi è rifiutato. Ogni risultato porta il proprio tenant di origine ("tenant": "2"), perché un id è univoco solo nel suo tenant; una posizione porta anche il proprio hash Zobrist ("zobrist"), che designa la stessa scacchiera in tutti i tenant. limit si applica a ogni tenant; 0 vale 1000, e un valore maggiore è rifiutato. In uno stream NDJSON, un errore su un tenant tardivo arriva come ultima riga, dopo i risultati dei tenant già letti: l’intero stream fallisce allora.

curl -s http://127.0.0.1:8080/v1/across.matchesList \
  -H 'X-Tenant-ID: 1' -H 'X-Read-Tenants: 2, 3' -d '{"limit":20}'

Ogni scrittura resta in X-Tenant-ID: nessun’altra route legge X-Read-Tenants. Senza l’header, una lettura across.* riguarda solo X-Tenant-ID. Un header malformato (un nome, un elemento vuoto, più di 64 tenant) o inviato su più righe rifiuta l’intera richiesta, qualunque sia la route. Su SQLite, che ha un solo tenant, l’elenco può contenere solo 1: l’header non amplia nulla. Queste route sono proprie del server: l’app desktop e call hanno un solo tenant.

Una richiesta across.* costa fino a 64 letture sullo storage, ma il limite di frequenza (--rate-limit-rps) la conta una sola volta, per X-Tenant-ID: dimensionare di conseguenza il database e questo limite, oppure far limitare l’elenco dal proxy. Il log di accesso di una route across.* riporta l’elenco ricevuto (campo read_tenants). L’header non figura tra gli header CORS consentiti: solo il proxy lo scrive, mai un browser.

Backup e ripristino

Quattro gesti, a seconda di ciò che si vuole recuperare.

Tutto, sotto PostgreSQL — pg_dump è lo strumento, e blunderDB non ha nulla da aggiungere:

pg_dump --format=custom --file=blunderdb.dump "postgres://…"
pg_restore --dbname="postgres://…" blunderdb.dump

Tutto, sotto SQLite in container — il file è aperto in modalità WAL (il demone codifica journal_mode(WAL) nella sua stringa di connessione, per tutte le connessioni del pool): accanto a blunderdb.db vivono un -wal e uno -shm, e le scritture più recenti stanno nel -wal. Copiare il solo .db di un demone in funzione dà quindi un file incompleto, senza che nulla lo segnali. Due modi sicuri:

  • fermare il demone, poi copiare l’intero volume — all’arresto i tre file sono coerenti, ed è il volume, non il solo .db, l’unità da salvare;

  • non copiare affatto il file: /v1/exports.sqlite (qui sotto) scrive un .db completo mentre il demone è in funzione, ed è l’unico gesto che non richiede alcuna interruzione.

/ops/maintenance.vacuum reincorpora sì il WAL nel file principale prima di riscriverlo, ma non congela il database: la scrittura successiva riparte nel WAL. È un comando di compattazione, non un metodo di backup.

Un tenant da solo — /v1/exports.sqlite scrive il database di un tenant in un normale file .db, quello che l’applicazione desktop apre:

curl -X POST http://127.0.0.1:8080/v1/exports.sqlite \
  -H "X-Tenant-ID: 42" -o tenant-42.db

Questo comando viene eseguito sulla macchina del demone: punta al listener locale, aggira il proxy, e imposta quindi da solo l’intestazione del tenant. Dall’esterno, è il proxy che si interroga, e il tenant è quello dell’account autenticato — l’intestazione non va fornita, il proxy cancella quella del client prima di iniettare la propria:

curl -u alice:… -X POST \
  https://blunderdb.example.com/v1/exports.sqlite -o tenant-alice.db

Rimettere quel file al suo posto — migrate lo ricopia sotto il tenant voluto:

./blunderdb migrate --from tenant-42.db --to "postgres://…" --tenant-id 42

migrate rifiuta di scrivere in un tenant che contiene già qualcosa, e dice cosa (« 128 posizioni, 3 match ») ; --on-conflict skip procede comunque e lascia che la deduplicazione Zobrist fonda le posizioni.

Ciò che migrate non copia, e che annuncia alla fine con il conteggio esatto: i mazzi Anki e le loro carte, la libreria dei filtri, le cronologie di ricerca e dei comandi, e lo stato di sessione. Sono dati d’uso dell’applicazione desktop; le posizioni a cui rimandano sono invece state spostate.

Le soglie di errore e di blunder, invece, vengono copiate: non sono dati d’uso ma l’abitudine di lettura da cui dipendono i conteggi, e un tenant che contasse diversamente dal file da cui proviene farebbe della migrazione un muto cambiamento di senso.

Il tenant imposta le proprie tramite POST /v1/librarySettings.load e /v1/librarySettings.save. Contrariamente a metadata, che è un’infrastruttura globale esposta in sola lettura, la tabella delle impostazioni porta un tenant_id e vive sotto Row-Level Security: un tenant che scrive le proprie soglie raggiunge soltanto le proprie righe.

La postazione di lavoro e il server

L’applicazione desktop apre file .db, non URL: non si connette ad alcun demone serve, e non esiste da nessuna parte un campo dove digitare un indirizzo. Il server e la postazione di lavoro si scambiano file, con due gesti simmetrici:

  • dal server alla postazione — POST /v1/exports.sqlite scrive l’intero tenant corrente in un .db che l’applicazione desktop apre così com’è (vedere Backup e ripristino);

  • dalla postazione al server — blunderdb migrate ricopia un .db sotto il tenant voluto (vedere Migrare un database SQLite verso PostgreSQL).

Non esiste alcuna lettura inter-tenant. La separazione è totale: nulla di ciò che un tenant memorizza è visibile a un altro, tramite nessuna rotta, e nessuna chiamata prende un tenant come parametro — ogni richiesta conosce solo quello che il proxy le ha imposto. Un allenatore che vuole vedere i match dei suoi allievi ha quindi due strade, entrambe esplicite:

  • aprirgli nel proxy un account supplementare, associato al tenant dell’allievo: è la tabella di corrispondenza del proxy, mai il demone, a decidere il tenant che una sessione vede;

  • chiedergli un’esportazione — il .db prodotto da exports.sqlite o dalla finestra di esportazione dell’applicazione desktop — e aprirlo sulla propria postazione.

Deployment con Docker

Il repository fornisce un Dockerfile.serve che costruisce un’immagine container minima del demone: viene compilato solo il binario serve (Go puro, senza interfaccia grafica e senza CGO, quindi collegato staticamente), poi collocato in un’immagine distroless.

# build
docker build -f Dockerfile.serve -t blunderdb-serve .

# run
docker run --rm -p 127.0.0.1:8080:8080 \
    -e BLUNDERDB_DSN="postgres://user:pass@host:5432/blunderdb?sslmode=disable" \
    blunderdb-serve

La build si lancia dalla radice del repository, e il backend predefinito dell’immagine è postgres.

L’immagine ascolta sulla porta 8080 e si configura tramite variabili d’ambiente (BLUNDERDB_BACKEND, BLUNDERDB_DSN, BLUNDERDB_ADDR, BLUNDERDB_RLS). Dichiara un HEALTHCHECK che ogni 30 secondi lancia blunderdb healthcheck (una richiesta a /readyz — l’immagine distroless non ha né curl né shell): docker ps mostra lo stato healthy o unhealthy del container, e Compose o un orchestratore possono attendere che il demone sia pronto prima di avviare ciò che ne dipende.

Immagine pubblicata

Non è necessario costruire l’immagine da soli: ogni versione pubblicata di blunderDB spinge la propria sul registro GitHub (GHCR), sotto il nome ghcr.io/kevung/blunderdb-serve. Sono disponibili due tag: il numero di versione, fissato per sempre su questa immagine, e latest, che segue l’ultima versione pubblicata. Tutta la documentazione li indica come ghcr.io/kevung/blunderdb-serve:<version>: è il numero di una versione pubblicata che prende il posto di <version>, ed è questa forma, mai latest, che un deployment di produzione fissa. L’immagine è fornita per linux/amd64 e linux/arm64; Docker sceglie l’architettura dell’host.

# pull
docker pull ghcr.io/kevung/blunderdb-serve:<version>

# postgres
docker run --rm -p 127.0.0.1:8080:8080 \
    -e BLUNDERDB_DSN="postgres://user:pass@host:5432/blunderdb?sslmode=disable" \
    ghcr.io/kevung/blunderdb-serve:<version>

# sqlite
docker run --rm -p 127.0.0.1:8080:8080 \
    -v blunderdb-data:/data \
    -e BLUNDERDB_BACKEND=sqlite -e BLUNDERDB_DSN=/data/blunderdb.db \
    ghcr.io/kevung/blunderdb-serve:<version>

/data è il punto di montaggio che l’immagine predispone, con i diritti del suo utente non privilegiato, e il suo XDG_DATA_HOME: il volume che vi si monta non serve solo alla base, le tabelle di bearoff vi sono calcolate una volta, in /data/blunderdb, e ritrovate agli avvii successivi. Senza volume, sono ricalcolate a ogni avvio del container — qualche secondo — e il demone lo segnala all’avvio se non può scriverle (could not prepare the bearoff tables; the exact regime will be unavailable), nel qual caso serve normalmente, con il solo regime stimato sulle posizioni di uscita.

L’immagine porta le etichette OCI usuali (org.opencontainers.image.source, .version, .revision, .licenses): docker inspect indica da quale commit e quale versione proviene. È costruita dall’integrazione continua a partire dal Dockerfile.serve del repository, esattamente come sopra; costruirla localmente o tirare l’immagine pubblicata dà lo stesso binario.

Avvertimento

Come il demone stesso, il container non effettua alcuna autenticazione (ADR-0005): si fida dell’header X-Tenant-ID così come lo riceve. Deve essere collocato dietro un reverse-proxy incaricato dell’autenticazione, che imposta questo header da sé, e non essere mai esposto direttamente su Internet pubblico. Gli esempi precedenti pubblicano la porta su 127.0.0.1 solo per questa ragione, e allo stesso modo --addr si lega a 127.0.0.1: il proxy è sulla stessa macchina.

Distribuzione dietro un proxy autenticante

L”ADR-0005 fa del reverse-proxy tutto intero il confine di sicurezza del demone: solo lui autentica il chiamante, solo lui ha il diritto di impostare l’header X-Tenant-ID, e deve rimuovere sistematicamente qualsiasi valore inviato dal client prima di iniettarvi il tenant autenticato — altrimenti chiunque può spacciarsi per qualsiasi tenant nominandolo da sé. Il modello di minaccia si riassume in una frase: il demone presuppone una rete interna di fiducia, e chiunque lo raggiunga direttamente è, per lui, il tenant che pretende di essere. Il repository fornisce un esempio completo, pronto all’uso, nella directory deploy/. Vive nel repository git, non nell’immagine container: occorre quindi clonare il repository, oppure scaricare i due file riprodotti qui sotto insieme a deploy/.env.example nella stessa directory.

Il file Compose mette Caddy — autenticazione HTTP Basic dimostrativa — davanti a blunderdb-serve e PostgreSQL, con Row-Level Security abilitata. Solo Caddy pubblica una porta: gli altri due servizi vivono su una rete Docker dichiarata internal: true, priva di rotta sia verso l’host sia verso Internet, qualunque siano i ports: che una modifica successiva vi aggiungesse.

deploy/docker-compose.yml
# Example deployment of `blunderdb serve` behind an authenticating reverse
# proxy — the security model ADR-0005 requires and, until now, that no example
# in this repository actually showed. See deploy/README.md for the threat
# model and doc/source/mode_headless.rst for the full walkthrough.
#
# Try it from the repository root:
#   POSTGRES_PASSWORD=changeme docker compose -f deploy/docker-compose.yml up -d --build
#   curl -u alice:demo-password http://localhost:8080/v1/metadata.counts -d '{}'
#   docker compose -f deploy/docker-compose.yml down -v

services:
  # Caddy is the ENTIRE security boundary (ADR-0005): it is the only service
  # with a published port, it authenticates every request, and it is the
  # only thing allowed to set X-Tenant-ID — see Caddyfile. Any reverse proxy
  # capable of stripping and re-setting a header works equally well; Caddy is
  # used here for its one-file config and built-in Basic Auth with no extra
  # modules. deploy/nginx-tenant-proxy.conf shows the equivalent nginx
  # snippet for an existing nginx deployment.
  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "8080:80" # the ONLY port this compose project exposes to the host
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config
    networks:
      - edge # the published port lives here — "backend" is internal-only
      - backend
    depends_on:
      blunderdb-serve:
        condition: service_healthy

  # No `ports:` here — on purpose (ADR-0005). The daemon performs no
  # authentication of its own, so it must be reachable only from Caddy, over
  # the "backend" network, and never published to the host.
  blunderdb-serve:
    build:
      context: ..
      dockerfile: Dockerfile.serve
    restart: unless-stopped
    environment:
      BLUNDERDB_BACKEND: postgres
      BLUNDERDB_DSN: "postgres://blunderdb:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD, see deploy/.env.example}@postgres:5432/blunderdb?sslmode=disable"
      BLUNDERDB_ADDR: ":8080"
      # Row-Level Security: defence-in-depth *inside* the trust boundary
      # Caddy draws above — it does not replace the proxy (ADR-0005).
      BLUNDERDB_RLS: "true"
    volumes:
      # The bearoff tables are computed on first start and kept under
      # $XDG_DATA_HOME/blunderdb, which the image sets to /data: without a
      # volume they are recomputed at every restart of the container.
      - blunderdb-data:/data
    networks:
      - backend
    depends_on:
      postgres:
        condition: service_healthy

  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: blunderdb
      POSTGRES_USER: blunderdb
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD, see deploy/.env.example}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U blunderdb -d blunderdb"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  postgres-data:
  # Bearoff tables computed by blunderdb-serve on first start (see
  # XDG_DATA_HOME above): a few megabytes, worth keeping across restarts.
  blunderdb-data:
  caddy-data:
  caddy-config:

networks:
  # Caddy's own network, carrying the one published port. A container on
  # "backend" alone (blunderdb-serve, postgres) is never reachable through it.
  edge: {}
  # internal: true means this network has no route to the outside world and
  # accepts no published ports — blunderdb-serve and postgres can only ever
  # be reached by another container attached to it (here, only Caddy),
  # never from the host or the public internet, regardless of what `ports:`
  # a future edit might add to either service.
  backend:
    internal: true

Il Caddyfile autentica, associa l’account autenticato all’intero del tenant (map), quindi lo inietta in X-Tenant-ID dopo aver esplicitamente cancellato qualsiasi valore ricevuto dal client: la guardia header_up X-Tenant-ID "" precede l’iniezione, cosicché un header inviato dal client non può raggiungere il demone qualunque siano le modifiche successive al file.

Lo stesso vale per X-Read-Tenants (Leggere più tenant): il proxy rimuove quello del client e lo imposta solo se conosce la relazione tra gli account; gli esempi del repository non ne conoscono alcuna e lo rimuovono sempre.

deploy/Caddyfile
# Demonstration reverse-proxy for `blunderdb serve` (ADR-0005).
#
# This is the WHOLE security boundary of the daemon: it authenticates the
# caller (here, HTTP Basic Auth — swap for forward_auth to a real identity
# provider, or an OIDC plugin, in production) and is the only thing allowed
# to set X-Tenant-ID. blunderdb-serve trusts that header completely and
# performs no authentication of its own.
#
# Demo credentials — CHANGE THESE before using this anywhere but a laptop:
#   alice / demo-password
#   bob   / demo-password
# Generate a real hash with:
#   docker run --rm caddy:2-alpine caddy hash-password --plaintext '<password>'
{
	# This demo terminates plain HTTP on a fixed port instead of Caddy's
	# automatic HTTPS, which needs a real public domain name to obtain a
	# certificate for. Point a domain at this host, replace ":80" below with
	# that domain, and delete these two lines to get HTTPS for free.
	auto_https off
	admin off
}

:80 {
	basic_auth {
		alice $2a$14$7yGnM3/IY8G/.mcBHMbDveecDQbnJnvHPcJQZcbWqn.H.mpttw9/.
		bob $2a$14$7yGnM3/IY8G/.mcBHMbDveecDQbnJnvHPcJQZcbWqn.H.mpttw9/.
	}

	# Map the authenticated login (Caddy sets {http.auth.user.id} once
	# basic_auth succeeds) to the tenant's positive integer — the only
	# spelling of X-Tenant-ID the daemon accepts (ADR-0005, amendment
	# 2026-09-03). This is the identity-to-tenant mapping ADR-0005 says is
	# the proxy's job: the daemon never sees "alice", only "1".
	map {http.auth.user.id} {tenant_id} {
		alice 1
		bob 2
		default 0
	}

	# Never reach the daemon's operator routes or its metrics through the
	# public proxy: /ops/ (vacuum, tenant purge) acts beyond the calling
	# tenant, /metrics needs no X-Tenant-ID and describes the whole daemon.
	@private path /ops/* /metrics
	respond @private 403

	reverse_proxy blunderdb-serve:8080 {
		# Guard, then inject: clear whatever the client sent BEFORE setting
		# the authenticated value, so a client-supplied X-Tenant-ID can never
		# reach the daemon no matter how this file is edited later — the
		# second line is the only one that can still be in effect once both
		# have run.
		header_up X-Tenant-ID ""
		header_up X-Tenant-ID {tenant_id}
		# X-Read-Tenants widens a read to other tenants (ADR-0063): only a
		# proxy that knows the relation (coach, club) may set it. This demo
		# knows none, so it drops whatever the client sent.
		header_up -X-Read-Tenants
	}
}

Altri due file completano la directory: deploy/nginx-tenant-proxy.conf riprende lo stesso schema in un estratto nginx (proxy_set_header X-Tenant-ID "" poi proxy_set_header X-Tenant-ID $tenant_id, con il blocco map $remote_user $tenant_id), per chi ha già un nginx in funzione; deploy/README.md enuncia il modello di minaccia e ciò che non va mai fatto.

L’autenticazione HTTP Basic del Caddyfile è una dimostrazione, non una raccomandazione per la produzione: si sostituisce con forward_auth verso un vero fornitore di identità (OIDC, SSO aziendale…), che autentica e poi trasmette l’identità nello stesso punto del file. Le due password e i due account della tabella di corrispondenza vanno sostituiti allo stesso modo.

deploy/Caddyfile.oidc ne è la ricetta OpenID Connect: Caddy interroga oauth2-proxy (forward_auth su /oauth2/auth), che risponde 202 con l’indirizzo dell’account connesso in X-Auth-Request-Email, oppure rimanda alla pagina di accesso del provider. Il blocco map associa questo indirizzo all’intero del tenant, e la stessa guardia header_up X-Tenant-ID "" precede l’iniezione. Il servizio oauth2-proxy da aggiungere al file Compose si trova all’inizio del file.

Quote per tenant

Un’istanza condivisa limita ciò che ogni tenant le sottrae con --quota-positions, --quota-analysis-seconds e --quota-imports (senza opzione, nulla è limitato). Il tempo di calcolo conta ogni calcolo del motore richiesto dal tenant: gammonnet.analyzeMissing, gammonnet.sweepStale, gammonnet.compare, gammonnet.cubeMatrix, gammonnet.evaluate, rollout.position e rollout.filter. Si conta in secondi CPU: il tempo trascorso moltiplicato per il numero di ricerche condotte contemporaneamente, così che un calcolo ripartito su tutti i core costa quanto lo stesso lavoro svolto posizione per posizione. Esaurito il tempo del giorno, queste route rispondono 429 con il codice quota_exceeded. Una scansione o un rollout.filter in corso conserva ciò che ha registrato e termina con l’evento quota_exceeded invece di done; un rollout.position interrotto risponde 429 e non registra nulla; un confronto interrotto restituisce ciò che ha raccolto con quotaExceeded: true e, in gathered, il numero di posizioni che doveva esaminare. Il conteggio riparte da zero a mezzanotte UTC e vive in memoria: un riavvio del demone lo azzera. La quota di posizioni viene verificata all’inizio di un’importazione, che non viene interrotta a metà: un tenant può superarla di quanto aggiungono le sue importazioni in corso. positions.save e le altre scritture singole non la verificano. Ogni rifiuto riporta in details il limite (quota, limit) e l’uso (used). tenants.quota restituisce al tenant chiamante i limiti e il suo uso: posizioni memorizzate, secondi di calcolo del giorno, importazioni in corso.

Le quote sono una contabilità del demone, non una frontiera: si applicano al tenant che il proxy ha impostato in X-Tenant-ID.

Scenario completo, da zero a un demone che risponde:

git clone https://github.com/kevung/blunderDB.git
cd blunderDB/deploy
cp .env.example .env    # POSTGRES_PASSWORD
docker compose up -d --build

# 401
curl -i http://localhost:8080/v1/metadata.counts -d '{}'

# 200
curl -u alice:demo-password http://localhost:8080/v1/metadata.counts -d '{}'
curl -u alice:demo-password -H "X-Tenant-ID: 999" \
     http://localhost:8080/v1/metadata.counts -d '{}'

docker compose logs blunderdb-serve
docker compose down -v

La prima richiesta è respinta da Caddy, ancora prima di raggiungere il demone. Le due successive sono autenticate come « alice », che la tabella di corrispondenza associa al tenant 1: restituiscono lo stesso corpo ({"positions":0,"analyses":0,"matches":0,…}) e il log del demone porta tenant=1 per entrambe — il valore 999 inviato dal client non è sopravvissuto alla guardia del Caddyfile. Questo scenario è stato rigiocato così com’è.

Per tirare l’immagine pubblicata invece di costruirla, sostituire in docker-compose.yml le tre righe build: del servizio blunderdb-serve con una riga image:, poi lanciare docker compose up -d senza --build:

blunderdb-serve:
  image: ghcr.io/kevung/blunderdb-serve:<version>
  restart: unless-stopped

Il file Compose pubblica la porta di Caddy su tutte le interfacce (8080:80): è ciò che ci si aspetta da un proxy, che è lì per essere raggiunto. Ciò che non va mai pubblicato è il demone — e infatti non lo è, non ha alcun ports:.

Aggiornare un deployment

Lo schema viene migrato automaticamente all’avvio, e questa migrazione è a senso unico: un database migrato verso uno schema recente non è più leggibile da una versione precedente di blunderDB (vedere Appendice: Schema del database). L’ordine dei gesti conta quindi.

  1. Salvare prima di tutto, prima di ogni altra cosa: è l’unica marcia indietro (vedere Backup e ripristino).

  2. Tirare il tag della versione voluta, mai latest in produzione. latest segue l’ultima versione pubblicata: il deployment che lo fissa cambia versione a ogni riavvio, senza che lo si sia deciso né che il backup della fase 1 sia necessariamente recente.

  3. Riavviare il demone sulla nuova immagine. Migra lo schema prima di servire la minima richiesta; se la migrazione fallisce, si arresta sull’errore invece di servire un database migrato a metà.

  4. Verificare la sonda di disponibilità. GET /readyz risponde 200 e {"status":"ready","version":"…"} quando l’archiviazione risponde e il suo schema è quello del binario; 503 e {"status":"down"} quando il database è irraggiungibile; 503 e {"status":"version_mismatch","version":"…","expected":"…"} quando i due schemi differiscono — la risposta nomina quello del database e quello che il binario si aspetta. blunderdb healthcheck restituisce lo stesso verdetto come codice di uscita.

Un version_mismatch che persiste dopo il riavvio è un ritorno indietro: un binario più vecchio davanti a un database già migrato. Non esiste una migrazione discendente; è il backup della fase 1 che va ripristinato.

Importante

Prima di attivare --read-tenants su un deployment esistente, aggiornare il proxy: un proxy configurato prima di questo header rimuove solo X-Tenant-ID e inoltrerebbe così com’è un X-Read-Tenants inviato dal client, che leggerebbe allora altri tenant. Senza l’opzione, il demone rifiuta questo header: un proxy che lo lascia passare si riconosce dalle risposte 400.

Backend PostgreSQL e multiutente

Per un deployment condiviso, blunderDB può memorizzare i dati in PostgreSQL invece che in un file SQLite. Il backend è selezionato tramite --backend postgres e la stringa di connessione --dsn. Lo schema viene creato e migrato automaticamente all’avvio.

I dati sono separati per tenant (locatario): ogni richiesta porta l’identificativo del proprio tenant (header X-Tenant-ID, un intero decimale positivo come 1 o 42), il che permette a più utenti di condividere la stessa istanza senza vedere i dati degli altri. Un identificativo che non sia un tale intero — un nome come alice o default, 0, 007 — viene rifiutato con 400 invalid: è il reverse-proxy ad associare un account al suo intero, il demone non indovina mai.

Row-Level Security

L’opzione --rls attiva in aggiunta la Row-Level Security di PostgreSQL. A ogni avvio, il demone installa su ogni tabella che porta un tenant_id una politica tenant_isolation che lascia passare solo le righe del tenant nominato dal parametro di sessione current_setting('app.tenant_id'), e la impone fino al proprietario della tabella (FORCE ROW LEVEL SECURITY). Questo parametro viene impostato sulla connessione quando esce dal pool e azzerato al suo ritorno; una connessione senza tenant non vede alcuna riga e non ne inserisce alcuna. È una difesa in profondità facoltativa, disattivata per impostazione predefinita: il filtraggio per tenant del codice applicativo resta comunque in vigore in entrambi i casi.

  • Il ruolo di connessione deve essere ordinario: né superuser, né BYPASSRLS. PostgreSQL lascia che questi due attraversino tutte le politiche senza una parola, e l’isolamento torna a essere solo quello del codice applicativo. Questo stesso ruolo deve però possedere le tabelle, poiché è lui a eseguire gli ALTER TABLE e i CREATE POLICY.

  • Su un database già popolato, non c’è nulla da migrare: l’installazione delle politiche è DDL idempotente, rieseguito a ogni avvio dopo la migrazione dello schema. Nessun dato viene spostato, nessuna riga riscritta; attivare o rimuovere --rls è solo un riavvio.

  • Il costo è misurato: sulla lettura di una posizione, 101,8 µs senza, 177,0 µs con, ossia +73,8 % — stesso container, stesse righe, due pool che differiscono solo per questo flag. Si paga a ogni prestito di connessione dal pool (impostazione e poi azzeramento del parametro) e sul predicato in più che ogni richiesta attraversa, mai sul volume di dati.

Aprire e chiudere un tenant

Non c’è nulla da creare lato server: un tenant non è un record, è l’intero che portano le sue righe. Il database non ha una tabella dei tenant e il demone non ne tiene alcun elenco — aprire un account significa aggiungere una voce alla tabella di corrispondenza del proxy, ed è la prima scrittura del membro a far esistere il suo tenant.

Un tenant vuoto risponde come un database vuoto, senza errore: metadata.counts restituisce zeri e gli elenchi non restituiscono nulla.

Quando un tenant viene dismesso, POST /ops/tenant.purge elimina definitivamente tutti i suoi dati (posizioni, match, collezioni, cronologia, ecc.) sul tenant corrente (quello indicato da X-Tenant-ID), oltre al suo stato di sessione (ultima ricerca, ultima posizione, schede aperte — le righe della tabella session_state che portano questo tenant): l’operazione viene eseguita in un’unica transazione, è idempotente (nessun errore nel purgare un tenant già vuoto o nel ripetere la chiamata) e non ha effetto su nessun altro tenant. Cancella le righe di questo tenant in tutte le tabelle che ne portano uno e lascia solo ciò che non appartiene a nessuno: la tabella metadata, con la sua riga globale di versione dello schema, e il registro delle migrazioni. Il tenant purgato ridiventa quindi esattamente un tenant vuoto, e il suo intero viene riassegnato. È disponibile solo con il backend PostgreSQL — restituisce un errore invalid su un backend SQLite, che non ha alcuna nozione di tenant.

Compattazione e pool di connessioni

POST /ops/maintenance.vacuum compatta il file SQLite del daemon — il corrispettivo del pulsante « Compatta il database » dell’interfaccia grafica e del comando blunderdb vacuum (vedere Interfaccia a riga di comando (CLI)), con la stessa protezione sullo spazio su disco — e restituisce le dimensioni prima e dopo (sizeBefore, sizeAfter, in byte). È disponibile solo con il backend SQLite; su PostgreSQL, che non ha un file da compattare, restituisce un errore invalid.

Il pool di connessioni PostgreSQL si regola tramite variabili d’ambiente: BLUNDERDB_POSTGRES_MAX_CONNS (50 per impostazione predefinita), BLUNDERDB_POSTGRES_MIN_CONNS (5), BLUNDERDB_POSTGRES_MAX_CONN_LIFETIME (1h), BLUNDERDB_POSTGRES_HEALTH_CHECK_PERIOD (30s), BLUNDERDB_POSTGRES_CONNECT_TIMEOUT (5s — oltre tale soglia, un database irraggiungibile fallisce rapidamente invece di bloccarsi sul timeout TCP del sistema operativo) e BLUNDERDB_POSTGRES_MAX_CONN_IDLE_TIME (30m — una connessione aperta per un picco di traffico non resta indefinitamente nel pool una volta passato il picco). Ogni valore è una durata in formato Go (5s, 30m, 1h); se assente o non valida, si applica il valore predefinito. Quando --metrics è attivo, lo stato del pool è esposto in continuo su /metrics: blunderdb_pg_pool_acquired (connessioni attualmente in uso), _idle (disponibili), _max (il limite configurato) e _wait_count (il numero cumulativo di chiamate Acquire che hanno dovuto attendere una connessione libera).

Migrare un database SQLite verso PostgreSQL

blunderdb migrate copia un database SQLite monoutente in un backend PostgreSQL, sotto un tenant scelto — l’intero che il reverse-proxy invierà in X-Tenant-ID per quell’utente — è la via per « caricare » una libreria desktop in un’installazione server.

blunderdb migrate \
    --from sqlite:///path/to/database.db \
    --to   "postgres://user:pass@host:5432/db?sslmode=disable" \
    --tenant-id 42

# --dry-run
blunderdb migrate --from sqlite:///path/to/database.db \
    --tenant-id 42 --dry-run

La migrazione copia le posizioni, le loro analisi e commenti, i match (partite + mosse), i tornei (con i loro collegamenti ai match) e le collezioni (con la loro composizione), riassegnando le chiavi primarie ed esterne, il tutto in una sola transazione lato destinazione: l’operazione è atomica (un errore lascia la destinazione intatta, basta riavviarla). L’avanzamento e il bilancio finale vengono emessi in NDJSON sullo standard output. Se il database di origine è abbastanza vecchio da richiedere il proprio aggiornamento di schema sul posto, questo viene eseguito per primo ed emette i propri eventi "schema-migration" (fase/fatto/totale) prima che inizi la copia riga per riga.

Opzione

Predefinito

Significato

--from <uri>

–

database SQLite di origine (sqlite:///<percorso> o un semplice percorso)

--to <dsn>

–

DSN PostgreSQL di destinazione (postgres://…)

--tenant-id <n>

–

tenant di destinazione, un intero decimale positivo (obbligatorio salvo in --dry-run; un nome come mon-tenant viene rifiutato)

--dry-run

–

conta ciò che verrebbe copiato senza scrivere nulla

--on-conflict <politica>

""

"" interrompe se il tenant ha già dei dati; skip unisce (deduplicazione delle posizioni tramite hash Zobrist)

Nota

Non vengono (ancora) migrati gli stati applicativi: deck/carte Anki, libreria di filtri, cronologia di ricerca e di comandi, e metadati di sessione. La priorità è la migrazione della libreria di posizioni e della cronologia dei match.

Il dispatcher generico call

In aggiunta ai sottocomandi storici (Interfaccia a riga di comando (CLI)), blunderdb call espone tutte le operazioni di archiviazione direttamente, in locale. Passa per gli stessi gestori del demone serve: il comportamento è quindi identico a POST /v1/<famiglia>.<metodo>. È utile per lo scripting e i test di integrazione.

# --list
blunderdb call --list

# read
blunderdb call metadata.counts --db database.db
blunderdb call positions.list  --db database.db --json '{"limit":10}'
blunderdb call matches.get     --db database.db --json '{"id":1}'

# write
blunderdb call positions.save  --db database.db --json '{"position":{...}}'
blunderdb call matches.delete  --db database.db --json '{"id":42}'

# a gesture of a tournament Direction, with the version a read printed
blunderdb call directions.enterResult --db database.db --if-match '…' \
  --json '{"tournamentId":3,"matchId":"m7","winner":"aa"}'

Opzioni:

Opzione

Predefinito

Significato

--db <percorso>

–

file SQLite (scorciatoia per --backend sqlite --dsn <percorso>)

--backend <tipo>

sqlite

sqlite o postgres

--dsn <stringa>

$BLUNDERDB_DSN

stringa di connessione del backend

--scope <n>

1

tenant, un intero decimale positivo (inviato come X-Tenant-ID; un nome come alice viene rifiutato)

--json <stringa>

{}

corpo della richiesta in formato JSON

--json-file <percorso>

–

legge il corpo della richiesta da un file

--list

–

mostra tutti i metodi <famiglia>.<metodo> ed esce

--if-match <version>

–

versione inviata in If-Match, richiesta dai gesti di direzione (I gesti di direzione) e di trascrizione (Trascrivere tramite l’API)

call serve i gesti di trascrizione senza flag: lavora su un file locale, come la CLI. Ogni chiamata è un processo nuovo, quindi una sessione propria: il sessionId può essere omesso e non c’è annullamento da una chiamata all’altra.

La risposta JSON (o il flusso NDJSON per gli endpoint *.list) viene scritta sullo standard output. In caso di errore, il processo termina con un codice diverso da zero e l’envelope {"error":{…}} viene stampato sullo standard output per restare analizzabile (per esempio con jq). Una risposta che porta un’intestazione Direction-Version la stampa sullo standard error: è il valore che il gesto successivo passa a --if-match. call serve i gesti di direzione senza opzione, come la CLI, perché viene eseguito in locale.

Strumenti per un assistente IA (MCP)

blunderDB non incorpora alcun modello linguistico: offre i suoi strumenti all’assistente che usate già (Claude Code, Claude Desktop, un client locale), tramite il Model Context Protocol. L’assistente cerca, legge e spiega; blunderDB risponde con le proprie cifre.

Gli strumenti passano per gli stessi gestori di /v1 e call:

Strumento

Cosa restituisce

database_overview

conteggi, periodo delle partite, versione dello schema, giocatori frequenti

search_positions

posizioni di una ricerca nella grammatica della barra dei comandi (descritta nello strumento), con la sua forma canonica

search_comments, saved_searches

commenti che contengono determinate parole; ricerche salvate

get_position

una posizione, la sua analisi (mosse migliori o cubo), la mossa giocata e il commento

explain_error

il tema dell’errore, il suo costo in millipunti e la decisione migliore

similar_positions, decode_position, legal_moves, race_epc

posizioni vicine; lettura di un XGID; mosse legali; EPC di corsa

list_players, player_stats, recurring_errors, training_stats

giocatori; PR globale, pedine, cubo, per fase; errori ricorrenti; PR del quiz e ritenzione Anki rispetto al PR reale

list_matches, get_match, list_tournaments

partite, dettaglio di una partita, tornei

list_collections, collection_positions, study_decks

raccolte e le loro posizioni; mazzi di ripasso

quiz_draw, quiz_grade

estrae una posizione senza la sua risposta, poi valuta la risposta data

evaluate

valutazione gammonNet di una posizione data come testo, senza salvarla: mosse migliori o decisione di cubo

anki_next

la prossima carta in scadenza di un mazzo di ripasso

transcribe_list, transcribe_get, transcribe_mat

trascrizioni di partite; dettaglio di una trascrizione; il suo testo .mat

direction_list, direction_standings, direction_season

tornei diretti; classifica di un torneo; classifica di stagione

rollout

rollout di una posizione del database: equity, intervallo al 95 % e JSD per candidato

Solo cinque strumenti scrivono — save_position, comment_position, create_collection, add_to_collection e anki_review, che valuta una carta estratta da anki_next — e sono offerti solo su richiesta: --write in locale, --mcp-write sul demone. Tutti gli altri si limitano a leggere; rollout guadagna però, quando la scrittura è offerta, l’argomento store, che registra il rollout accanto all’analisi della posizione. Nessuno strumento cancella nulla.

In locale, l’assistente lancia blunderdb mcp su un file (vedi Interfaccia a riga di comando (CLI)). Per Claude Code:

claude mcp add blunderdb -- blunderdb mcp --db /chemin/vers/base.db

Sul demone, gli stessi strumenti rispondono in HTTP su POST /mcp (trasporto streamable HTTP, senza sessione). Come /v1, /mcp richiede X-Tenant-ID e ogni strumento lavora in quel tenant; anche un programma che incorpora pkg/blunderdb/server lo serve. Il demone non autentica nessuno (ADR-0005): /mcp si protegge al proxy come /v1, e --mcp-write vi si decide come --direction. Ogni chiamata /v1 fatta da uno strumento ripercorre l’intera catena del demone: viene registrata, contata nelle metriche e addebitata al limite di frequenza del tenant, oltre alla richiesta /mcp che la trasporta. Una chiamata di strumento costa quindi più richieste; nessuna ne è esentata.

Come call, blunderdb mcp migra lo schema di un database meno recente all’apertura, anche senza --write.