Headless-Modus (Server)

Bemerkung

Dieser Abschnitt beschreibt einen fortgeschrittenen und optionalen Modus von blunderDB, der für Server-Bereitstellungen, den Mehrbenutzerbetrieb und die Automatisierung gedacht ist. Die normale und empfohlene Nutzung von blunderDB bleibt die Desktop-Anwendung, die in den vorherigen Kapiteln beschrieben wird. Wenn Sie blunderDB allein auf Ihrem Computer verwenden, benötigen Sie diesen Modus nicht: Sie können dieses Kapitel überspringen, ohne dabei Analysefunktionen zu verpassen.

Überblick

Dasselbe Binärprogramm blunderdb kann zusätzlich zur Desktop-Anwendung und den Kommandozeilenbefehlen (siehe Befehlszeilenschnittstelle (CLI)) im Headless-Modus laufen: ohne grafische Oberfläche, vollständig über die Kommandozeile oder über das Netzwerk gesteuert. Dieser Modus umfasst drei Anwendungsfälle:

  • der Daemon serve — stellt die Engine von blunderDB als HTTP-+-JSON-Dienst bereit, um eine gemeinsame Datenbank auf einem Server zu betreiben und mehrbenutzerfähig darauf zuzugreifen;

  • der generische Dispatcher call — ruft jede beliebige Speicheroperation direkt und lokal auf, für Skripting und Tests;

  • der Befehl migrate — überträgt eine SQLite-Einzelbenutzerdatenbank in ein mehrbenutzerfähiges PostgreSQL-Backend.

Diese drei Anwendungsfälle stützen sich auf eine gemeinsame Speicherschicht, die mit zwei Backends umgehen kann: SQLite (das übliche .db-Dateiformat der Desktop-Anwendung) und PostgreSQL (für mehrbenutzerfähige Server-Bereitstellungen).

Der Daemon serve

blunderdb serve startet die Engine als HTTP-Dienst, der mit JSON antwortet. Damit lässt sich eine Stellungsdatenbank auf einer Maschine hosten und von mehreren Clients aus darauf zugreifen.

# 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

Bemerkung

sslmode=disable eignet sich nur für ein vertrauenswürdiges privates Netzwerk — eine Datenbank in einem benachbarten Container, in einem Netzwerk ohne Route zum Host oder ins Internet. Für eine entfernte Datenbank verschlüsselt sslmode=require die Verbindung, und verify-full prüft zusätzlich das Zertifikat des Servers und seinen Hostnamen. Die übrigen Verbindungszeichenketten dieser Seite tragen aus demselben Grund sslmode=disable: Sie beschreiben alle ein privates Netzwerk.

Warnung

Der Daemon führt keinerlei Authentifizierung durch. Er vertraut dem Anfrage-Header X-Tenant-ID und muss hinter einem Reverse-Proxy (nginx, Caddy …) laufen, der für die Authentifizierung zuständig ist. Setzen Sie ihn niemals direkt dem öffentlichen Internet aus.

X-Tenant-ID ist die Ganzzahl des Tenants (1, 2, 42…): Es ist Aufgabe des Reverse-Proxys, das authentifizierte Konto dieser Ganzzahl zuzuordnen. Ein Name (alice) wird mit 400 invalid abgewiesen, nie umgewandelt.

Optionen:

Option

Standard

Bedeutung

--db <Pfad>

–

SQLite-Datei (Kurzform für --backend sqlite --dsn <chemin>)

--backend <type>

sqlite

Speicher-Backend: sqlite oder postgres

--dsn <Zeichenkette>

$BLUNDERDB_DSN

Verbindungszeichenkette des Backends

--addr <Host:Port>

:8080

Lausch-Adresse

--log-level <Stufe>

info

Protokollierungsstufe: debug|info|warn|error

--metrics

true

stellt /metrics bereit (Prometheus-Format)

--web

false

stellt die Web-Seite zum Nachschlagen unter /app/ bereit; standardmäßig aus, siehe unten

--direction

false

stellt die Turnier- und Veranstaltungs-Leitungsgesten bereit; standardmäßig aus, siehe Die Leitungsgesten

--mcp-write

false

bietet die Schreibwerkzeuge von /mcp an; standardmäßig aus, siehe Werkzeuge für einen KI-Assistenten (MCP)

--transcription

false

stellt die Transkriptionszüge bereit (transcriptions.create, apply, finish…); standardmäßig aus, siehe Transkribieren über die API

--transcription-ttl <Dauer>

30m

schließt eine Transkriptionssitzung, die länger als diese Dauer inaktiv war

--cors-allow-origin <Ursprung>

–

aktiviert CORS für diesen Ursprung, eine durch Kommas getrennte Liste von Ursprüngen, oder * (standardmäßig deaktiviert); die Antwort spiegelt den Ursprung der Anfrage nur wider, wenn er in der Liste steht, mit Vary: Origin

--rate-limit-rps <n>

50

Anfragenlimit pro Sekunde und pro Tenant (0 = deaktiviert); standardmäßig auf einen großzügigen Wert aktiviert statt optional, damit eine Compose-Datei, die nur an die Datenbank denkt, nicht versehentlich einen Daemon ganz ohne Limit erbt

--rate-limit-burst <n>

100

Größe des Token-Buckets für Anfragenspitzen

--quota-positions <n>

0

Positionen, die ein Tenant speichern darf, zu Beginn eines Imports geprüft: Ist die Grenze erreicht, wird der Import abgelehnt (413, storage_quota_exceeded); positions.save und die anderen Einzelschreibvorgänge sind nicht begrenzt; 0 = unbegrenzt

--quota-analysis-seconds <n>

0

CPU-Sekunden Engine-Rechenzeit pro Tenant und pro UTC-Tag (429, quota_exceeded); 0 = unbegrenzt

--quota-imports <n>

0

gleichzeitig laufende Importe desselben Tenants (429, quota_exceeded); 0 = unbegrenzt

--rls

false

PostgreSQL: aktiviert Row-Level Security pro Tenant (Verteidigung in der Tiefe, optional)

--read-tenants

false

berücksichtigt den Header X-Read-Tenants bei across.*-Lesezugriffen; ist die Option deaktiviert, wird er abgelehnt (400) — siehe Mehrere Tenants lesen

--bearoff-ts <Datei>

–

optionale zweiseitige Bearoff-Datenbank (.bd), die die Tabelle TS-06-06 für die Rennanalyse des EPC-Endpunkts erweitert; der Daemon lädt nie eine Datenbank herunter — siehe Die Bearoff-Datenbanken

--identity-dir <Verzeichnis>

–

Verzeichnis der Signaturidentität des Daemons (wird beim ersten Gebrauch angelegt); notwendig, damit exports.sqlite ein Wasserzeichen anbringen kann — siehe unten

--ops-addr <Host:Port>

–

stellt die Familie /ops/ (maintenance.vacuum, tenant.purge) auf einer von --addr getrennten Adresse bereit und nimmt sie von dieser herunter; leer (der Standard) belässt sie auf dem Hauptlistener, wo das Ablehnen des Präfixes Sache des Proxys ist — siehe Die Betriebsrouten

--pprof-addr <Host:Port>

–

stellt net/http/pprof auf einer von --addr getrennten Adresse bereit (standardmäßig aus); nur zum Debuggen — diese Endpunkte kennen keine Mandanten und liefern ein Speicher- oder CPU-Profil des gesamten Prozesses; niemals öffentlich oder auf derselben Adresse wie /v1 bereitstellen

Die meisten Optionen können auch über eine Umgebungsvariable angegeben werden (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): Ein explizites Flag hat Vorrang vor der entsprechenden Variable.

Der Daemon hat keine Option für ein Datenverzeichnis: Er schreibt seine Bearoff-Tabellen nach $XDG_DATA_HOME/blunderdb, oder andernfalls nach ~/.local/share/blunderdb. Es ist also XDG_DATA_HOME, die sie verschiebt — siehe Die Bearoff-Datenbanken.

Die Bucket-Tabelle des Rate-Limiters trägt selbst eine harte Obergrenze (10.000 verschiedene Tenants): darüber hinaus verdrängt jeder neue Tenant den am längsten nicht genutzten Bucket, statt die Tabelle unbegrenzt wachsen zu lassen — nützlich, wenn ein Client zwischen zwei periodischen Bereinigungen inaktiver Buckets viele verschiedene X-Tenant-ID-Werte sendet, ob absichtlich oder nicht.

blunderdb serve weist nun jedes unerwartete Positionsargument zurück (abgesehen von dem einen führenden serve, das ein bereits auf das nackte Binary reduziertes ENTRYPOINT durchlässt): ohne diese Prüfung wurde ein nach einem solchen Argument platziertes Flag stillschweigend ignoriert — docker run image serve --addr :9090, ein natürlicher Reflex, da das ENTRYPOINT des Images bereits serve lautet, startete bisher wortlos auf :8080.

Endpunkte

Der Dienst stellt stets vorhandene Betriebs-Endpunkte bereit:

  • GET /healthz — Lebendigkeit (der Prozess läuft);

  • GET /readyz — Bereitschaft (der Speicher antwortet und sein Schema hat die erwartete Version);

  • GET /metrics — Prometheus-Metriken (wenn --metrics aktiv ist);

  • GET /app/ — die Web-Seite zum Nachschlagen (wenn --web aktiv ist).

Die Web-Seite

blunderdb serve --web stellt eine Seite unter /app/ bereit: eine Bibliothek, die sich vom Tablet oder Telefon aus einsehen lässt, ohne irgendetwas zu installieren.

Sie kann drei Dinge, und diese Liste ist die Entscheidung, keine Etappe:

  • eine Stellung, ihre Analyse und ihr Brett einsehen;

  • suchen, mit derselben Token-Grammatik wie die Befehlszeile der Anwendung;

  • einen Anki-Stapel wiederholen — Antwort aufgedeckt und Note vergeben.

Sie kann keine Stellung bearbeiten, nicht importieren, nicht löschen, keine Sammlungen, Matches, Turniere oder Einstellungen verwalten, und wird es nicht lernen. Eine hier fehlende Funktion ist keine Lücke: Sie ist der Umfang.

Sie ist standardmäßig aus, und dieser Standard ist die Entscheidung. Der Daemon authentifiziert niemanden: Er vertraut dem Header X-Tenant-ID und muss hinter einem authentifizierenden Proxy laufen. Eine im Browser erreichbare Oberfläche ab Werk einzuschalten, lüde genau das Deployment ein, das diese Regel verbietet.

Die Seite sendet keinen Tenant: Der Proxy setzt den Header, wie bei jedem anderen Client. In der lokalen Entwicklung, und nur dort, benennt /app/?tenant=1 einen — was nichts an der Sicherheit eines Daemons ändert, der diesen Header ohnehin von jedem annimmt.

Die Dateien der Seite werden ohne Tenant ausgeliefert, mit Absicht: Ein Browser muss die Seite laden können, bevor der Proxy ihm etwas zuweist, und eine Seite enthält keine Daten.

Lebendigkeit und Bereitschaft beantworten zwei verschiedene Fragen. /healthz antwortet immer mit 200, sobald der Prozess Anfragen bedient, ohne je den Speicher abzufragen: Ein Orchestrator startet einen Container neu, dessen Lebendigkeit fehlschlägt, und eine vorübergehend nicht erreichbare Datenbank darf einen gesunden Daemon nicht in einer Schleife neu starten. /readyz antwortet mit 503 (mit status gleich down oder version_mismatch), solange die Datenbank nicht antwortet oder ihr Schema nicht das der Binary ist: Der Verkehr wird einfach umgeleitet, bis sie zurück ist.

Der Unterbefehl blunderdb healthcheck (auch in der serve-Binary des Container-Images enthalten) führt eine GET /readyz-Anfrage an den lokalen Daemon aus und liefert 0, wenn er bereit ist, sonst 1; die Adresse ist die von --addr oder BLUNDERDB_ADDR, standardmäßig :8080. Er ist der HEALTHCHECK des Docker-Images und eignet sich genauso für ein Skript oder eine systemd-Unit:

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

Die fachliche Oberfläche folgt dem Schema POST /v1/<Familie>.<Methode> (zum Beispiel /v1/positions.save, /v1/matches.get). Die Familien umfassen die Positionen, die Analysen, die Matches, die Kommentare, die Sammlungen, die Turniere, die Anki-Karten, die Filter, die Sitzungen, den Verlauf (Suche und Befehle), die Suche, die Metadaten, die Bibliothekseinstellungen, die Statistiken, den Import und den Export. Die Auflistungs-Endpunkte liefern einen NDJSON-Strom (ein JSON-Objekt pro Zeile). Der Server wird bei SIGINT / SIGTERM sauber beendet.

Ein Fehler liefert den Umschlag {"error":{"code":…,"message":…}}. Der Code not_found besagt, dass eine benannte Ressource nicht existiert; unknown_route, ebenfalls 404, besagt, dass der Daemon die aufgerufene Methode nicht bedient: Client und Daemon in verschiedenen Versionen, oder eine Familie, die der Daemon nur mit einem Schalter bedient. Ein Client schließt nur bei not_found auf fehlende Daten.

positions.save liefert {"id":…,"created":…}. created ist true allein für den Aufruf, der die Position eingefügt hat, und das sagt der Schreibvorgang selbst: Ein Client, der eine Position und dann ihre Analyse kopiert und die Kopie nach einem Fehler rückgängig machen muss, löscht die Position nur, wenn er sie angelegt hat – ohne den Wettlauf eines vorherigen positions.exists.

Was /v1 verspricht

Ein gegen /v1 geschriebener Client muss weiter funktionieren. Die Regel passt in drei Zeilen und ist aufgeschrieben nützlicher als erraten:

  • Was existiert, ändert seine Bedeutung nicht. Eine /v1-Route wird weder umbenannt noch entfernt noch umgewidmet. Ein Anfrage- oder Antwortfeld wird weder umbenannt noch entfernt noch im Typ geändert.

  • Was hinzukommt, kommt hinzu. Eine neue Route, ein optionales Anfragefeld, ein neues Feld in einer Antwort: ein Client, der sie ignoriert, funktioniert weiter — das ist die hier gewählte Definition von „kompatibel“. Ein Client muss unbekannte Felder daher ignorieren statt sie abzulehnen.

  • Alles Übrige ist /v2. Ein zuvor optionales Feld verpflichtend machen, eine Einheit ändern, die Bedeutung eines Fehlercodes ändern: das sind Brüche, und sie leben unter einem anderen Präfix, neben /v1, solange die Clients hinüberwechseln.

Zwei Klarstellungen, die zählen. Die /ops/-Routen sind nicht abgedeckt: sie betreiben ein Deployment, ändern sich mit ihm und sind keine API für fremde Programme. Und der Vertrag selbst wird erzeugt aus der Routentabelle des Daemons (openapi.yaml, API-Vertrag): er kann nichts anderes beschreiben als das, was der Server ausliefert.

Transkribieren über die API

Die Familie transcriptions.* erlaubt einem externen Client, ein Match Zug um Zug zu transkribieren, mit derselben Logik wie der Desktop. Die Lesezugriffe (list, get, exportMat, losses) werden immer bereitgestellt. Die Züge (create, open, editMatch, apply, undo, redo, close, finish, abandon) nur mit serve --transcription: ohne dieses Flag antworten diese Routen mit 404.

create und open liefern den Zustand des Entwurfs, seine revision und eine sessionId. apply, undo, redo, close und finish nennen diese sessionId: fehlend → 400, abgelaufene oder unbekannte Sitzung → 410; der Client öffnet den Entwurf dann erneut (open), Cursor am Dokumentende. abandon nennt keine Sitzung: Es löscht den Entwurf allein unter der Revision aus If-Match. Jeder schreibende Zug trägt die zuletzt gesehene Revision im Header If-Match und liefert die nächste:

  • fehlendes If-Match → 428;

  • veraltete Revision → 409; die Fehlerhülle nennt die aktuelle Revision (details.revision) und den frischen Zustand des Entwurfs (details.state: Dokument, Revision, Sitzung und Cursor), den der Client anzeigt, bevor er seinen Zug wiederholt, falls er noch gilt.

Die Revision rückt nur vor, wenn sich das Dokument ändert (Kopfzeile und Aktionen): den Cursor zu bewegen oder einen Würfel der laufenden Aktion einzugeben schreibt nichts und liefert dieselbe Revision. Eine Sitzung gehört zum Entwurf, nicht zu einem Client: open liefert die lebende Sitzung, wenn es eine gibt, und die Registerkarten oder Rechner, die sie teilen, teilen auch den Cursor und den Rückgängig-Stapel.

Die Sitzung behält nur den Rückgängig-Stapel, den Cursor und die laufende Eingabe: der Entwurf wird nach jedem Zug geschrieben, der ihn ändert, sodass eine verlorene Sitzung (Inaktivität, Neustart, andere Instanz) keinen Zug verliert. transcriptions.get liefert die Revision als ETag und antwortet mit 304 auf ein If-None-Match, das sie nennt.

finish speichert das Match und löscht den Entwurf, abandon löscht ihn ohne Match, close gibt nur die Sitzung frei. editMatch öffnet einen Entwurf zu einem bestehenden Match und liefert bei einem importierten Match die Zahl der Analysen und Kommentare, die die Transkription nicht behält (losses.lossy). Die Analyse des gespeicherten Matches wird mit gammonnet.analyzeMissing gestartet.

Warnung

Der Daemon authentifiziert niemanden: Schreibzugriff zu öffnen heißt, ihn dem Proxy anzuvertrauen (Bereitstellung hinter einem authentifizierenden Proxy). Eine Rolle „Transkribent“ ist eine Proxy-Regel auf dem Präfix /v1/transcriptions., kein Begriff des Daemons.

Ein Python-Client

clients/python/ enthält einen minimalen Client ohne Abhängigkeit jenseits der Standardbibliothek — der Daemon spricht POST und JSON, was urllib und json vollständig abdecken:

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

Er besteht absichtlich aus zwei Hälften. _generated.py trägt eine Methode je Route, erzeugt aus der Routentabelle des Daemons durch go run ./cmd/openapi-gen: eine handgeschriebene Oberfläche würde an dem Tag abdriften, an dem eine Route hinzukommt, und niemand würde es bemerken, bevor ein Nutzer es tut. client.py trägt den Transport — die Session, den Tenant-Header, die Fehlerhülle, das NDJSON-Lesen — und ist von Hand geschrieben. Was sich mit der API ändert, wird erzeugt; was sich mit dem Urteil ändert, nicht.

Methodennamen sind familie_operation in snake_case: aus /v1/positions.loadByIds wird positions_load_by_ids(). Die Familie bleibt erhalten, weil mehrere Familien einen Operationsnamen teilen (list, delete) und ein nacktes list() kollidieren würde.

events() folgt /v1/events und liefert pro Nachricht ein Dictionary (siehe Über Gesten benachrichtigt werden: /v1/events).

Ein Fehlschlag löst APIError aus, der die Hülle des Daemons unverändert trägt: den code (worauf ein Programm verzweigt), die message (was ein Mensch liest), den HTTP-Status und die Details.

Die Engine in ein Go-Programm einbetten

pkg/blunderdb/server.Bootstrap öffnet den Speicher und liefert einen Satz Handler im aufrufenden Prozess, ohne auf einem Port zu lauschen. Es ist der Eingang für einen vertrauenswürdigen Aufrufer — gammonGo — der die Stellungsbibliothek will, ohne einen Daemon daneben laufen zu lassen oder mit sich selbst HTTP zu sprechen.

Was das voraussetzt, ist ausgesprochen: der Aufrufer ist vertrauenswürdig. Es gibt keinen Tenant zu prüfen, keinen Header zu validieren, keinen Rate-Limiter — das gehört dem Daemon, weil er einem Netz gegenübersteht, und ADR-0005 sagt warum. Ein Programm, das die Engine einbettet, wählt seinen Tenant selbst und steht für seine Aufrufe ein.

Turnierleitung und Veranstaltungen

Am Arbeitsplatz geleitete Turniere und die Veranstaltungen, die sie bündeln (rencontre in der API und ihren Routen /v1/rencontres.*), werden über die API unter dem Tenant des Aufrufers gelesen, mit demselben Code wie am Arbeitsplatz. Das Lesen wird immer bedient; die Gesten (ein Ergebnis eingeben, Paarungen bilden, eine Veranstaltung anlegen) nur unter serve --direction (Die Leitungsgesten).

  • directions.list und directions.directory lesen den gesamten Tenant: die Liste der geleiteten Turniere, das Spielerverzeichnis.

  • Die übrigen directions.* erwarten {"tournamentId": N}: directions.get (die vollständige Ansicht: Vorschläge, Rangliste, laufende Matches), directions.participants, directions.freeParticipants, directions.tableGrid, directions.brackets, directions.standings, directions.standingsCsv, directions.history (optionale Filter player und match), directions.clock, directions.slots, directions.lastDecision, directions.pageHtml und directions.pairingSheetHtml (mit round).

  • rencontres.list, dann rencontres.get und rencontres.pageHtml mit {"id": N}. rencontres.pageHtml rendert die Wandseite des Raums, ein eigenständiges HTML-Dokument im Feld html: Ein Wandbildschirm zeigt es an und lädt es regelmäßig neu.

  • rencontres.ranking liefert die Saisonwertung, wie blunderdb tournament ranking --season: rencontreId, from, to, points, participation und elo, alle optional; ohne rencontreId und ohne Zeitraum zählen alle geleiteten Turniere des Tenants.

Die Seiten werden auf Französisch gerendert, der Sprache der Leitungs-Engine. Ein Turnier, das nicht geleitet wird oder zu einem anderen Tenant gehört, antwortet mit 404.

Bedingte Lesezugriffe. Jede dieser Routen liefert einen ETag-Header. Wird er in If-None-Match zurückgesendet, erhält man 304 ohne Body, solange sich nichts geändert hat, was die Route liest. Jeder Schreibzugriff ändert den ETag sofort: eine Aktion im Turnier oder in einem Turnier derselben Veranstaltung, die Zuordnung eines Matches, ein von einem Slot aus begonnener Entwurf, die Umbenennung eines Turniers, eine Änderung der Veranstaltung. Die Antwort 304 spielt kein Turnier neu ab, sodass eine Wandseite, die alle paar Sekunden abfragt, kaum Kosten verursacht. Nur was von der Uhrzeit abhängt, bildet eine Ausnahme: Vorschläge, Uhr und Seiten werden zum Zeitpunkt des Lesens berechnet, und ein ETag gilt daher höchstens eine Minute. Ein Client, der neu liest, sieht so eine Frist oder eine Pause innerhalb der Minute verstreichen.

Diese Routen sind POST. Für dieses Verb antwortet RFC 9110 (§13.1.2) mit 412 auf ein zutreffendes If-None-Match. Der Daemon antwortet dennoch mit 304: Der Body der Anfrage enthält nur die Parameter eines Lesezugriffs ohne Wirkung, der sich wie ein GET verhält. Die Form If-None-Match: * wird abgelehnt (400), da sie keine Antwort bezeichnet, die der Client bereits hätte. Eine ungültige Anfrage (zum Beispiel ein negatives round) wird vor jeder Bedingung abgelehnt.

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

Wie der Rest von /v1 authentifizieren diese Routen niemanden: Hinter dem Proxy (Bereitstellung hinter einem authentifizierenden Proxy) liest jeder, der das Präfix /v1/directions. eines Tenants erreicht, dessen Turniere, einschließlich der Spielernamen. Ein Proxy, der diese Lesezugriffe bestimmten Benutzern vorbehält, tut dies mit einer Regel auf diesem Präfix und auf /v1/rencontres..

Die Leitungsgesten

blunderdb serve --direction öffnet die Gesten, die der Arbeitsplatz auf einem geleiteten Turnier und auf einer Veranstaltung ausführt. Ohne dieses Flag antworten diese Routen mit 404, als wären sie nicht vorhanden. call bedient sie immer.

  • directions.create (tournamentId, config, seed), directions.setConfig und directions.previewConfig (config, die Konfiguration im JSON-Format der Engine);

  • die Anmeldungen: directions.enterParticipants (players), directions.addParticipant (name, club, rating; mit section und key erhält ein Nachzügler einen Freilos-Platz), directions.updateParticipant, directions.withdraw, directions.reinstate, directions.makeAbsent, directions.makeAvailable, directions.addPair, directions.updatePair;

  • der Ablauf: directions.confirmProposal (action, wie von directions.get vorgeschlagen), directions.confirmAllProposals, directions.startMatch, directions.enterResult, directions.enterForfeit, directions.moveMatchToTable, directions.cancelMatch, directions.correctResult, directions.close, directions.reopen, directions.addNote, directions.attachMatch, directions.detachMatch;

  • die Veranstaltung: rencontres.create, rencontres.update, rencontres.attach, rencontres.detach, rencontres.trash, rencontres.setTableOutOfService, rencontres.setBreaks;

  • die Tischeigenschaften: rencontres.setTables (id, tableSettings, ein Eintrag pro Tisch, der welche trägt: Nummer, Name, Saal, reserviert, zugewiesen an), rencontres.setEventRooms (id, tournamentId, rooms, die Säle, in denen der Wettbewerb gespielt wird; keiner bedeutet alle Tische) und directions.setTables (tournamentId, tableSettings) für einen Wettbewerb, der allein gespielt wird.

Eine Turniergeste liefert die vollständige Ansicht des Turniers, wie directions.get; eine Veranstaltungsgeste liefert die Veranstaltung. Der Dienst schreibt anschließend die Anzeigeseiten in den Ordner um, den die Datenbank bestimmt, wie am Arbeitsplatz. Eine Seite, die sich nicht schreiben lässt (Ordner verschwunden, Datenträger voll), bricht die Geste nicht ab: Die Antwort trägt für jede nicht geschriebene Seite einen Header Direction-Page-Warning (tournament 3, rencontre 2), ohne den Serverpfad, und der Arbeitsplatz zeigt ihn in seiner Statusleiste an.

Eine Geste, die die Regeln ablehnen (leerer Name, besetzter Tisch, noch nicht begonnenes Turnier, von der Engine abgelehnte Konfiguration), liefert 400 mit der Begründung. Ein Ausfall des Daemons oder seiner Datenbank liefert 500, ohne Details: Die Begründung bleibt im Journal des Daemons.

Version erforderlich. Jedes Lesen eines Turniers oder einer Veranstaltung liefert einen Header Direction-Version, und jede Geste sendet ihn in If-Match zurück:

  • ohne If-Match (oder mit *) wird die Geste abgelehnt: 428;

  • hat seit diesem Lesen jemand geschrieben, wird die Geste abgelehnt: 409. Das Feld details des Fehlers enthält den aktuellen Zustand und seine version: der Client liest neu und wiederholt seine Geste, falls sie noch gültig ist;

  • andernfalls wird die Geste angewendet und liefert die neue Version in Direction-Version.

Der Vergleich erfolgt in der Transaktion der Geste, unter einer Datenbanksperre (PostgreSQL-Advisory-Lock pro Turnier oder pro Veranstaltung, SQLite-Schreibsperre): Von zwei Gesten, die auf Grundlage derselben Lesung gesendet wurden, wird nur eine angewendet, ob sie über denselben Daemon, über zwei Daemons auf derselben PostgreSQL-Datenbank oder über den Arbeitsplatz und call auf derselben Datei laufen. Die Geste wird ganz oder gar nicht geschrieben. Ein in einer Veranstaltung gespieltes Turnier hat die Version seiner Veranstaltung, sodass eine Geste in einem Geschwisterwettbewerb sie ebenfalls ändert. directions.create und rencontres.create zielen auf nichts Bestehendes und nehmen keine Version.

Idempotenz. Eine Geste mit einem Idempotency-Key-Header wird nur einmal angewendet: Mit demselben Schlüssel erneut gesendet, liefert sie die erste Antwort mit ihren Headern (einschließlich Direction-Version) und Idempotency-Replayed: true. Ein Doppelklick oder eine Netzwiederholung erfasst nicht zwei Ergebnisse; zwei gleichzeitige Sendungen desselben Schlüssels führen die Geste nur einmal aus. Nur eine erfolgreiche Antwort wird behalten.

  • Der Schlüssel ist an den Anfragekörper gebunden: Derselbe Schlüssel mit einem anderen Körper liefert 422.

  • Die Wiedergabe geht der Versionsprüfung voraus: Sie liefert die behaltene Antwort ohne 428 oder 409, selbst wenn sich die Version seither geändert hat.

  • Die Schlüssel leben im Speicher jeder Daemon-Instanz, 24 Stunden lang, höchstens 1 000 pro Tenant: Ein Neustart vergisst sie, und eine andere Instanz kennt sie nicht.

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}'

Warnung

Der Daemon authentifiziert niemanden (ADR-0005). Mit --direction gibt jeder, den der Proxy durchlässt, Ergebnisse ein. Die Engine kennt keine Rolle (Leiter, Schiedsrichter, Leser): eine Rolle ist eine Regel des Proxys, der /v1/directions. und /v1/rencontres. den Leitern vorbehält oder nur Lesezugriffe durchlässt. Starten Sie --direction nie auf einem erreichbaren Daemon ohne diesen Proxy, auch nicht im WLAN eines Clubs.

Über Gesten benachrichtigt werden: /v1/events

GET /v1/events ist ein Server-Sent-Events-Strom (text/event-stream): eine Nachricht pro bestätigter Geste des Tenants, veröffentlicht nach dem Schreiben in die Datenbank, nie für eine abgelehnte oder abgebrochene Geste. Die Nachricht sagt, was sich bewegt hat, und seine neue Version, nicht den Zustand: Der Client liest das Angezeigte mit If-None-Match neu.

  • event: rencontre — rencontreId, tournamentIds (die Wettbewerbe der Veranstaltung, vor und nach der Geste) und version;

  • event: direction — tournamentId und version, für ein außerhalb jeder Veranstaltung gespieltes Turnier;

  • event: transcription — transcriptionId und revision; ein abgebrochener oder beendeter Entwurf trägt removed (und matchId bei Beenden).

removed: true zeigt an, was nicht mehr existiert. Die Route wird nur mit --direction oder --transcription bedient: ohne sie schreibt der Daemon nichts, was er ankündigen müsste, und /v1/events antwortet mit 404. Wie jede /v1/-Route verlangt sie X-Tenant-ID: Ein Abonnent hört nur seinen Tenant. Ein Tenant hält höchstens 16 offene Ströme gleichzeitig; darüber hinaus 429. Der Arbeitsplatz nutzt denselben Dienst, schaltet aber keinen Bus daran: Seine Gesten werden nicht angekündigt.

Die Parameter tournament, rencontre und transcription (durch Kommas getrennte oder wiederholte Kennungen) schränken das Abonnement ein: Eine Nachricht passiert, wenn sie eine davon nennt. Ein Turnier einer Veranstaltung erhält die Nachrichten seiner Veranstaltung. Ein unbekannter Parameter oder eine ungültige Kennung ergibt 400.

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

Kein Verlauf. Der Daemon speichert keine Nachricht. Jeder Strom beginnt mit event: resync und einer id: Der Client kann Gesten vor dem Verbinden oder zwischen zwei Verbindungen verpasst haben und liest alles Angezeigte neu. Der Grund ist reconnected, wenn die Anfrage Last-Event-ID trägt, sonst subscribed. Ein zu langsamer Abonnent, dessen Warteschlange von 64 Nachrichten voll ist, wird nach demselben resync getrennt: Er verzögert nie eine Geste. Der Strom kündigt eine Wiederverbindungsfrist von 3 Sekunden an.

Über einen Proxy. Alle 25 Sekunden wird ein : ping-Kommentar gesendet, damit ein Proxy einen stillen Strom nicht trennt; X-Accel-Buffering: no bittet nginx, ihn nicht zu puffern. Der Strom wird nicht komprimiert, entgeht dem Timeout gewöhnlicher Anfragen und zählt bei der Ratenbegrenzung nur als eine Anfrage. Das Beenden des Daemons schließt alle Ströme; ein während des Beendens angefordertes Abonnement erhält 503.

Mehrere Instanzen. Unter SQLite hält eine einzige Instanz die Datenbank: Der Bus im Arbeitsspeicher genügt. Unter PostgreSQL gibt jede Instanz, sobald --direction oder --transcription aktiv ist, ihre Aktionen über LISTEN/NOTIFY auf dem Kanal blunderdb_events an die anderen weiter: Ein mit einer Instanz verbundener Abonnent hört eine auf einer anderen Instanz bestätigte Aktion oder eine über call auf derselben Datenbank ausgelöste. Der Tenant wird in der Benachrichtigung mitgeführt, und die empfangende Instanz stellt sie nur den Abonnenten dieses Tenants zu. Jede Instanz öffnet zwei zusätzliche Verbindungen (application_name blunderdb-events-… zum Lauschen, blunderdb-notify-… zum Senden); eine Instanz, die beim Start nicht lauschen kann, weigert sich zu starten. call kündigt an, ohne zu lauschen, und bedient seine Anfrage auch dann, wenn er nicht ankündigen kann.

Jede zum Verbinden berechtigte Rolle kann auf diesem Kanal senden, auch unter --rls. Eine empfangene Benachrichtigung wird nur geglaubt, wenn ihr Tenant gültig und ihre Art bekannt ist; der Rest wird protokolliert und ignoriert. Eine gefälschte Benachrichtigung kann im schlimmsten Fall die Abonnenten eines Tenants dazu bringen, ihre Daten neu zu lesen.

  • Die Benachrichtigung wird nach dem Schreiben in die Datenbank gesendet, wie die lokale Nachricht. Zwei Verluste bleiben ohne resync: eine Instanz, die zwischen Schreiben und Benachrichtigung beendet wird, und ein Herunterfahren, das in 2 Sekunden nicht senden kann, was noch in der Warteschlange ist. Die Aktion ist bestätigt, aber die bereits geöffneten Ströme auf den anderen Instanzen erfahren erst bei der Wiederverbindung ihres Clients davon.

  • Eine verlorene Lauschverbindung wird wiederhergestellt, mit wachsender Wartezeit von 250 ms bis 30 s. Aktionen anderer Instanzen, die während des Ausfalls stattfanden, gehen verloren: Nach der Wiederherstellung erhält jeder Abonnent der Instanz ein resync mit dem Grund missed. Eine für PostgreSQL zu lange Benachrichtigung (8 000 Byte) oder eine, die eine Instanz nicht senden konnte, erreicht die anderen als dasselbe resync für den betroffenen Tenant.

  • Die id-Werte des Streams gelten nur für die jeweilige Instanz. Ein Client, den ein Load Balancer an eine andere Instanz schickt, hat davon nichts: Das resync, das jeden Stream eröffnet, lässt ihn neu einlesen, was er anzeigt.

Die Bearoff-Datenbanken

Der Daemon berechnet seine beiden Standardtabellen beim Start im Hintergrund (TS-06-06 für das Doppler-Urteil, OS-06 für den EPC): etwa sechs Sekunden eines Kerns, einmalig, in seinem Datenverzeichnis — $XDG_DATA_HOME/blunderdb, oder andernfalls ~/.local/share/blunderdb. Nichts wird heruntergeladen und nichts ist in die Binärdatei eingebettet (ADR-0027). Ist dieses Verzeichnis schreibgeschützt, werden die Tabellen für die Lebensdauer des Prozesses im Speicher gehalten: der Dienst startet, er zahlt die Berechnung einfach bei jedem Neustart.

Ein breiterer Bereich wird beim Start nicht berechnet — TS-06-11 wiegt 1,2 GB und dauert Minuten, das entscheidet ein Dienst nicht allein. Es ist Sache des Betreibers, ihn mit der CLI in dem Volume zu erzeugen, das der Daemon lesen wird:

# 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

Beim ersten Aufruf lässt man den Daemon die Tabelle selbst in seinem Datenverzeichnis finden; beim zweiten weist man sie über ihren Pfad zu, wo auch immer sie liegt. --data-dir ist eine Option der bearoff-Unterbefehle, niemals von serve.

blunderdb bearoff list --data-dir /srv/data/blunderdb sagt, was das Volume enthält und was jeder Bereich kosten würde; blunderdb bearoff verify endet bei einer beschädigten Tabelle mit einem Fehler, was es unmittelbar zu einer nutzbaren Startsonde macht. Einzelheiten siehe Befehlszeilenschnittstelle (CLI).

Die Betriebsrouten

Zwei Aufrufe machen nicht beim aufrufenden Mandanten halt und leben deshalb unter einem eigenen Präfix, POST /ops/<Familie>.<Methode>:

  • /ops/maintenance.vacuum (SQLite-Backend) schreibt die gesamte Datei neu, samt der Daten aller Mandanten, und hält währenddessen eine Schreibsperre;

  • /ops/tenant.purge (PostgreSQL-Backend) vernichtet die Daten eines Mandanten, und der vernichtete Mandant ist der, den der vom Aufrufer kontrollierte Header nennt.

Der Daemon authentifiziert niemanden (siehe unten): eine von einem Mandanten erreichbare Route ist eine Route, die jeder Mandant aufrufen darf. Das Präfix gibt es, damit der Proxy beide mit einer einzigen Regel ablehnen kann. /ops/ niemals über den öffentlichen Proxy bereitstellen. Unter nginx passt die Regel in eine Zeile des server-Blocks; unter Caddy in zwei Zeilen der Site:

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

Die Option --ops-addr <Host:Port> geht weiter: die beiden Routen verlassen dann die Adresse --addr und werden nur noch auf diesem zweiten Listener bereitgestellt, der an eine Verwaltungsschnittstelle zu binden ist. Ohne die Option bleiben sie auf dem Hauptlistener, und sie zu blockieren ist Sache des Proxys.

Diese Routen verlangen den Header X-Tenant-ID wie alle anderen — eine Bereinigung nennt den Mandanten, den sie vernichtet, und braucht diesen Header dringender als jede andere. Nur die Proben (/healthz, /readyz) und /metrics kommen ohne aus.

Deshalb deckt die obige Ablehnungsregel auch /metrics ab: Da kein Tenant erforderlich ist, kann es jeder lesen, der den Daemon erreicht, und es veröffentlicht die Größe der Datenbank und die laufende Arbeit, über alle Tenants hinweg. Es lässt sich von der Maschine des Daemons aus abfragen, oder über einen Pfad, den der Proxy dem Betrieb vorbehält. Der dritte Punkt, der niemals offengelegt werden darf, ist keine Route, sondern ein Listener: der von --pprof-addr, der keine Vorstellung von Tenants hat und ein Profil des gesamten Prozesses liefert. Er wird an eine Verwaltungsschnittstelle gebunden, niemals vom Proxy veröffentlicht.

Was nicht unter /ops/ gewandert ist: /v1/gammonnet.sweepStale. Der Nachlauf ist teuer, aber auf den aufrufenden Mandanten begrenzt; was ihn beschränkt, sind das Ratenlimit und die Messwerte laufender Arbeit, keine Vertrauensgrenze.

Der vollständige Vertrag — jede Methode, ihre Anfrage und ihre Antwort — wird aus dem Quelltext erzeugt und versioniert: openapi.yaml im Wurzelverzeichnis des Repositorys (OpenAPI-Format, samt Schemata) und sein lesbarer Anhang, API-Vertrag (eine Tabelle je Familie). Beide werden mit go run ./cmd/openapi-gen neu erzeugt, und ein eigener Test schlägt fehl, sobald eines von beiden hinter den tatsächlich registrierten Routen zurückbleibt.

Jede /v1-Anfrage akzeptiert einen JSON-Body (Content-Type: application/json, oder gar keinen Header — ein Body eines anderen Typs wird mit 400 invalid abgelehnt, statt mit einer verwirrenden JSON-Parse-Fehlermeldung zu scheitern); eine bekannte Methode, die mit dem falschen HTTP-Verb aufgerufen wird, antwortet mit 405, wobei der Allow-Header das einzige akzeptierte Verb nennt. Listen-Methoden, die ein limit entgegennehmen, lehnen mehr als 1000 Zeilen pro Seite ab (400 invalid), statt einen unbegrenzten Wert zu honorieren.

Jede listende Familie nimmt limit und offset: positions.list, positions.listIds, matches.list, search.find, anki.reviewLog, comments.listAll, tournaments.list und collections.positions. Beide sind standardmäßig null, was bedeutet, was es immer bedeutet hat: alles. Es gibt keine implizite Obergrenze — ein Strom wird nicht im Speicher gehalten, eine unbegrenzte Liste kostet also Zeit und Bandbreite, aber nie die Standfestigkeit des Daemons, während ein stilles Standardlimit einen Client eine abgeschnittene Liste für vollständig halten ließe. Was die beiden Parameter bringen, ist die Möglichkeit zu blättern, für wen es will.

Jede TCP-Verbindung ist pro Anfrage in Lese-/Schreibzeit begrenzt — ein großzügiges Budget für gewöhnliche Aufrufe, ein weit größeres für streamende Routen (NDJSON-Listen, Importe/Exporte, den gammonNet-Nachhol-Sweep) — und die Zahl gleichzeitig offener Verbindungen ist gedeckelt: darüber hinaus wartet eine weitere Verbindung, bis eine der bestehenden frei wird, statt dass jede Verbindung bedingungslos einen eigenen Ausführungsstrang bekommt. Ein geordnetes Herunterfahren (SIGINT/SIGTERM) bricht zunächst jeden laufenden Import und jeden gammonNet-Nachhol-Sweep ab — jeder antwortet mit einem abschließenden Ereignis {"event":"cancelled"}, statt dass seine Verbindung ohne Erklärung gekappt wird — bevor der Server innerhalb der üblichen Karenzzeit geschlossen wird. Die temporäre Datei eines hochgeladenen Imports behält von der ursprünglichen Erweiterung nur die dem Daemon bekannten (.xg, .xgp, .sgf, .mat, .bgf, .ogxm, .txt, .db, .dbx), und alle gleichzeitig laufenden Importe — über alle Tenants hinweg — teilen sich ein globales Kontingent an auf der Festplatte zwischengespeicherten Bytes: darüber hinaus wird ein neuer Import abgelehnt (too many requests), statt die Belegung von $TMPDIR unbegrenzt wachsen zu lassen.

/v1/imports.json liest einen JSON-Export von blunderDB ein und füllt dabei nur Lücken: Die mitgeführte Analyse wird nur bei einer Stellung geschrieben, die noch keine hat, ohne je eine vorhandene Analyse zu ersetzen, und die Rollouts beider Seiten bleiben erhalten.

Die Familie search bietet drei Türen zur selben Suche. search.find nimmt das vollständige Filterobjekt, Feld für Feld. search.query nimmt eine Abfrage in der Sprache der Befehlszeile der Anwendung entgegen (s cube p>30 E>50, beschrieben in Liste der Befehle) und streamt dieselben Stellungen; sie ist der einzige Weg, über das Netz die Filter zu erreichen, die kein offensichtliches Feld haben — Zugmuster, Kommentartext, Spieler, Datum, ausgeschlossene Würfe, Zonen und Blots. search.parse sucht nichts: Sie antwortet, was eine Abfrage bedeutet — die Filter, die sie bezeichnet, ihre kanonische Form (zwei gleichbedeutende Abfragen teilen sie, was eine gespeicherte Suche vergleichbar macht) und ihre Diagnosen.

Eine Abfrage mit einem Token, das nichts erkennt, wird abgelehnt (400 invalid, unter Nennung des Tokens), statt ausgeführt zu werden und die Suche stillschweigend einzuengen. Ein Token, das verstanden wird, hier aber keine Wirkung hat — x, das die Ausschlussstruktur einschaltet, die ein Brett und kein Text ist —, reist im Header X-BlunderDB-Query-Diagnostics, damit der Rumpf für alle bestehenden Clients NDJSON-Stellungen bleibt.

Zwei Methoden der positions-Familie dekodieren eine Stellung, ohne sie zu speichern: positions.fromXGID rekonstruiert eine Stellung aus einer XGID-Zeichenkette und positions.fromXGP aus einer Einzelstellungsdatei .xgp.

POST /v1/exports.sqlite exportiert den gesamten aktuellen Tenant — Positionen, Sammlungen, Matches, Turniere, Analysen, Kommentare, gespielte Züge, Filterbibliothek und Anki-Stapel — in eine SQLite-Datei, die sich unverändert auf dem Arbeitsplatzrechner öffnen lässt. Der JSON-Rumpf der Anfrage ist optional: watermarkOrigin / watermarkNote bringen ein mit der eigenen Identität des Daemons signiertes Wasserzeichen an (--identity-dir) — ohne diese Felder trägt der Export kein Wasserzeichen; sie ohne konfigurierte Identität anzufordern schlägt mit dem Code invalid fehl. collectionIds beschränkt den Export auf diese Sammlungen und ihre Positionen, mit Analysen, Kommentaren und gespielten Zügen, ohne die Filterbibliothek und die Anki-Stapel.

Eine Sammlung zwischen Tenants zu teilen läuft über den Client, nie über ein Lesen von einem Tenant in den anderen: Der abgebende Tenant ruft exports.sqlite mit collectionIds auf (und mit einem Wasserzeichen, damit der Empfänger weiß, woher die Datei stammt), der empfangende Tenant sendet die Datei an imports.db. Jede Anfrage trägt ihr eigenes X-Tenant-ID; der Proxy entscheidet, wer beides tun darf. Beim Import schließt sich eine Sammlung der gleichnamigen Sammlung des Empfängers an oder wird angelegt; ihre Positionen werden ohne Dubletten hinten angefügt. Eine lebende Sammlung des Empfängers erhält keine Position: Ihre Abfrage bestimmt ihren Inhalt. Der Import einer Datenbank in der Desktop-Anwendung folgt derselben Regel.

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

Die Familie training führt das Protokoll des Reiters Training: training.save fügt eine Sitzung hinzu (exercise, seedSource, Zähler, items) und liefert ihre id (Idempotency-Key wird akzeptiert); training.sessions liest die Sitzungen zurück, die jüngste zuerst (exercise und limit optional); training.numberStats aggregiert die Items einer Übung nach Zahlentyp. Die Fragen selbst werden vom Client gezogen.

gammonnet.evaluate bewertet eine nackte Stellung (position oder xgid), ohne im Tenant etwas zu lesen oder zu schreiben: mit Würfeln die besten Züge (candidates, standardmäßig 5, höchstens 20); ohne Würfel die Doppelentscheidung. ply reicht von 0 bis 2 (standardmäßig 2); eine tiefere Suche ist Aufgabe von analyzeMissing.

Die Familie anki gewinnt sechs Methoden hinzu, die den Planer für verteilte Wiederholung (FSRS) erweitern: anki.reviewLog (ein Protokoll jeder Wiederholung — Bewertung und FSRS-Ergebnis — als Grundlage für Retentionsstatistiken und eine originalgetreue Historie), anki.forecast (eine Vorausschau auf die Anzahl der in den kommenden Tagen fälligen Karten, überfällige Karten eingeschlossen), anki.suspendCard / anki.buryCard / anki.removeCard (eine Karte vorübergehend oder endgültig aus der Wiederholungswarteschlange entfernen) sowie anki.retention (die über die Wiederholungen eines Decks gemessene Erfolgsquote, abgeglichen mit dem von dessen Besitzer gesetzten Ziel).

Bemerkung

anki.retention ersetzt anki.optimizeParams, das die Zielretention in Richtung der beobachteten Rate verschob und sie schreiben konnte. Das Retentionsziel ist eine Entscheidung über den Kompromiss zwischen Last und Qualität, die gemessene Rate ist ihr Ergebnis, und das eine an das andere zu koppeln ist genau der Mechanismus, den die Autoren von FSRS ablehnen. Die Methode misst nur, ohne je zu schreiben.

Die Familie stats stellt stats.playerTable bereit: eine Statistikzeile je Spieler (Matches, Siege/Niederlagen, gezählte Entscheidungen, PR gesamt / Steine / Doppler, Snowie Error Rate, Fehler, Blunders und Glück) über die vom übergebenen Filter behaltenen Matches. Wie in der grafischen Oberfläche berücksichtigt diese Tabelle vom Filter nur Zeitraum, Turniere und Matchlänge: Die Spielerauswahl und der Entscheidungstyp werden ignoriert, da die Tabelle alle Spieler umfasst und Steine und Doppler bereits in getrennte Spalten aufteilt. Das Feld luck_known gibt an, ob das Glück für diesen Spieler gemessen wurde; luck_rate_mp darf nicht gelesen werden, wenn es false ist — unbekanntes Glück ist kein Glück von null.

Der an die stats-Methoden übergebene Filter akzeptiert neben PlayerName auch ein Feld PlayerAliases: die anderen Schreibweisen, unter denen dieselbe Person unterschrieben hat. Da der Name eines Spielers in jede Datei von Hand eingetippt wird, erscheint dieselbe Person regelmäßig in mehreren Schreibweisen, und ein Filter, der nur eine davon behält, rechnet über einen Teil der Matches, ohne dass etwas auffällig wirkt. Das Feld ist rein additiv: Die Entscheidungen zu jedem der Namen werden behalten. Die Namen in der Datenbank zusammenzuführen (MergePlayers) ist die andere Antwort und bleibt Datenbanken vorbehalten, die man nicht von jemand anderem erhalten hat — sie schreibt die Matches aller um.

Zwei Methoden vervollständigen die Parität mit der grafischen Oberfläche: stats.tournamentBadges liefert für jedes Turnier der Datenbank die auf seiner Karte angezeigte Kennzahl (PR des Referenzspielers), und matches.findByHash sagt anhand der beiden Duplikat-Fingerabdrücke, ob ein Match bereits vorhanden ist — genug, um einen überflüssigen Import zu vermeiden, bevor er beginnt.

Das Feld winner eines Spiels, von matches.createGame entgegengenommen und von matches.games zurückgegeben, hat nur eine Codierung: 1 für Spieler 1, -1 für Spieler 2, 0 für ein unbeendetes Spiel. Ein Client, der weiterhin 0, 1 oder -1 im Sinne von gnubg sendet (0 für Spieler 1, 1 für Spieler 2), trägt den umgekehrten Gewinner ein.

analyses.repair berechnet die denormalisierten Spalten einer Analyse (darunter cube_error) aus ihrer vollständigen Analyse neu und liefert die Anzahl der tatsächlich korrigierten Zeilen. Diese Spalten sind nur eine Projektion: Ein Projektionsfehler lässt sich daher ohne Neuimport der Quelldateien beheben. Der Vorgang ist explizit und wird nie von selbst ausgelöst — weder beim Öffnen einer Datenbank noch durch eine Migration, denn das Schema ist nicht die Ursache. Eine unlesbare Analyse bleibt unverändert, statt auf null gesetzt zu werden. Der bekannte Anwendungsfall: die von gnuBG als „Double No“ bezeichneten Nicht-Doppel, die vor Version 0.33.0 falsch gelesen wurden und den Fehler eines nie erfolgten Doppels trugen.

gammonnet.analyzeMissing löst die gammonNet-Nachholanalyse des aktuellen Tenants aus: eine Analyse für jede Stellung schreiben, die noch keine hat (ADR-0013, ADR-0015). Es ist eine Bibliotheks-Operation — sie liest und schreibt gespeicherte Stellungen und Analysen — und nie ein nackter Evaluator: blunderdb serve arbeitet auf einer Bibliothek, gammonnet serve bewertet eine Stellung. Die Antwort ist ein NDJSON-Strom (started, progress, dann done oder error/cancelled), nach demselben Muster wie die Import-Endpunkte; gammonnet.analyzeMissing.cancel (mit der im Ereignis started erhaltenen job_id) bricht eine laufende Nachholanalyse ab und dient gleichermaßen für eine Nachholanalyse wie für eine Neuanalyse (siehe unten). Es ist dieselbe Operation wie das automatische Auslösen nach einem Import und die explizite Geste der grafischen Oberfläche sowie der Unterbefehl blunderdb analyze (siehe Befehlszeilenschnittstelle (CLI)) — drei Formen, eine einzige Logik.

gammonnet.sweepStale ist das Gegenstück zu analyzeMissing für die Neuanalyse statt der Lückenfüllung: Jede Stellung, deren Analyse vollständig von gammonNet stammt, aber veraltet ist — eine ältere Engine-Version als die gerade laufende, oder eine andere Tiefe als ply —, wird in der angeforderten Tiefe neu bewertet. Das Veraltungsprädikat wird mit demselben Stapel der grafischen Oberfläche und mit blunderdb analyze --stale geteilt (keine doppelte Logik über die drei Modi hinweg); eine Stellung mit einer XG-, GNUbg- oder BGBlitz-Analyse wird nie angerührt, unabhängig von ihrem gammonNet-Inhalt — der Schutz von ADR-0013 bleibt bedingungslos. Dieselbe NDJSON-Form wie analyzeMissing, und das Abschlussereignis jeder der beiden Routen trägt die Aufteilung evaluated/refused/failed: Eine Stellung, die gammonNet zu bewerten ablehnt (ein Spielstand außerhalb der Reichweite seiner Tabelle, eine Dopplerentscheidung, die das Modell ablehnt), zählt als refused, nicht als failed — sie wird beim nächsten Durchlauf nie vergeblich erneut versucht, anders als eine tatsächlich fehlgeschlagene Stellung.

rollout.position spielt eine Stellung der Bibliothek (positionId) durch einen Rollout und liefert für jeden Kandidaten das Equity, dessen 95-%-Intervall und die JSD; rollout trägt die Einstellungen (fast, standard oder standard,ply=1…), store speichert den abgeschlossenen Rollout als zweite Analyse neben derjenigen der Stellung, die er nie ersetzt. Eine bloße Stellung (eine XGID) wird abgelehnt: Der Daemon arbeitet auf einer Bibliothek. rollout.filter ist die Stapelform von blunderdb analyze --rollout: Die von query (der Suchsprache) gewählten Stellungen, die noch keinen Rollout mit denselben Einstellungen tragen, werden nacheinander gespielt und laufend gespeichert, als NDJSON-Strom (started, progress nach jeder Partienserie, dann done, cancelled oder quota_exceeded); rollout.filter.cancel bricht ihn mit seiner job_id ab. Ein Tenant führt nur einen Stapel gleichzeitig aus, Rollout oder gammonNet. rollout.list liest die gespeicherten Rollouts einer Stellung.

Korrelation und fachliche Metriken

Jede Anfrage erhält eine Korrelations-Kennung: die, die der Client (oder ein Reverse-Proxy) im Header X-Request-Id sendet, sonst eine erzeugte — in beiden Fällen im selben Header der Antwort zurückgegeben und der abschließenden Logzeile der Anfrage hinzugefügt (Feld request_id). Ein ggf. vorhandener traceparent (W3C Trace Context) wird unverändert in dieselbe Logzeile übernommen — der Daemon analysiert und validiert ihn nicht und bindet keine Tracing-Bibliothek ein: es ist eine Brücke, um diese Logs mit einer vorgelagerten Tracing-Pipeline zu korrelieren, mehr nicht.

Über Anfragevolumen und Latenz hinaus veröffentlicht /metrics Messwerte zur laufenden Arbeit, die bei einem hängenden Import oder gammonNet-Lauf sonst unsichtbar bleibt (eine einzige sehr lange Anfrage, nicht viele Anfragen):

  • blunderdb_imports_inflight — laufende Importe, über alle Mandanten hinweg;

  • blunderdb_import_spool_bytes — derzeit auf dem Import-Spool-Kontingent reservierte Bytes (siehe --rate-limit-* oben für das Gegenstück bei Anfragen pro Sekunde);

  • blunderdb_gammonnet_sweep_inflight — laufende gammonNet-Nachläufe, über alle Mandanten hinweg;

  • blunderdb_database_size_bytes — Größe der SQLite-Hauptdatei bzw. pg_database_size unter PostgreSQL (die gesamte Datenbank, nicht pro Mandant, wie die Verbindungspool-Messwerte unten); fehlt, solange noch keine Messung veröffentlicht wurde.

Ein Speicher- oder CPU-Profil des Prozesses ist verfügbar, wenn mit --pprof-addr <Host:Port> gestartet wird (net/http/pprof): standardmäßig aus und bewusst auf einer von --addr getrennten Adresse, da diese Endpunkte keine Mandanten kennen.

Komprimierung der Ströme

NDJSON-Listen wiederholen in jeder Zeile dieselben Feldnamen. Der Daemon komprimiert sie, wenn der Client das akzeptiert: Accept-Encoding: gzip senden, und die Antwort kommt als Content-Encoding: gzip zurück. Gemessen an einer Match-Liste: 13,5 % der ursprünglichen Größe bei tausend Zeilen, 14,6 % bei hundert.

Die Komprimierung ändert nichts daran, dass der Strom inkrementell bleibt — jeder Datensatz geht wie bisher an den Client, nur eben komprimiert. Sie gilt nur für NDJSON-, JSON- und Textantworten: ein Datenbankexport oder ein .dbx-Container ist bereits komprimiert, ein erneutes Gzip würde ihn nur größer machen. Accept-Encoding: gzip;q=0 lehnt sie ausdrücklich ab.

Nur ein Mandant auf SQLite

Das SQLite-Backend hat keine Mandantenspalte: alle Daten liegen in denselben Tabellen, ohne Trennung. Auf diesem Backend lehnt der Daemon deshalb jedes X-Tenant-ID außer 1 ab — die anderen anzunehmen hieße, jedem die Zeilen aller zu liefern, hinter einem Header, der das Gegenteil behauptet. Ein Deployment mit wirklich mehreren Mandanten braucht das PostgreSQL-Backend.

Mehrere Tenants lesen

Ein Coach, der die Matches seiner Schüler liest, ein Club, der eine Bibliothek teilt: Die Beziehung zwischen diesen Konten liegt beim Host, der sie authentifiziert, nie im Daemon. Der Proxy drückt sie über den Header X-Read-Tenants aus, eine durch Kommas getrennte Liste von Tenants (X-Read-Tenants: 2, 3), die er neben X-Tenant-ID setzt. Der Daemon vertraut ihm wie X-Tenant-ID und autorisiert selbst nichts (ADR-0063).

Die Funktion ist standardmäßig deaktiviert, und deaktiviert heißt abgelehnt: Solange der Daemon nicht mit --read-tenants gestartet wird (oder mit BLUNDERDB_READ_TENANTS=true; Config.TrustReadTenants für einen Host, der die Engine einbettet), wird jede Anfrage mit einem nicht leeren X-Read-Tenants abgelehnt (400), unabhängig von der Route. Aktivieren Sie sie erst, wenn der Proxy so konfiguriert ist, dass er jeden vom Client gesendeten Wert entfernt und die Liste selbst setzt.

Nur die Lesezugriffe /v1/across.* beachten diesen Header. Die vollständige Liste: across.searchFind, across.matchesList, across.statsCompute und across.playerTable; sie lesen zuerst X-Tenant-ID, dann jeden aufgeführten Tenant in der Reihenfolge des Headers, insgesamt höchstens 64 verschiedene Tenants. Auf einem Tenant aus der Liste, mit der ID benannt: across.matchesGet, across.matchMovePositions (die Positionen eines Matches, Zug für Zug) und across.analysesLoadByIds; ein nicht in der Liste enthaltener Tenant wird dort abgelehnt. Jedes Ergebnis trägt seinen Herkunfts-Tenant ("tenant": "2"), da eine ID nur innerhalb ihres Tenants eindeutig ist; eine Position trägt außerdem ihren Zobrist-Hash ("zobrist"), der in allen Tenants dasselbe Brett bezeichnet. limit gilt für jeden Tenant; 0 bedeutet 1000, und ein größerer Wert wird abgelehnt. In einem NDJSON-Stream kommt ein Fehler bei einem späten Tenant als letzte Zeile, nach den Ergebnissen der bereits gelesenen Tenants: Der gesamte Stream schlägt dann fehl.

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}'

Jeder Schreibzugriff bleibt in X-Tenant-ID: Keine andere Route liest X-Read-Tenants. Ohne den Header betrifft ein across.*-Lesezugriff nur X-Tenant-ID. Ein fehlerhafter Header (ein Name, ein leeres Element, mehr als 64 Tenants) oder ein auf mehreren Zeilen gesendeter lässt die gesamte Anfrage ablehnen, unabhängig von der Route. Unter SQLite, das nur einen Tenant hat, kann die Liste nur 1 enthalten: Der Header erweitert dort nichts. Diese Routen gehören zum Server: Die Desktop-App und call haben nur einen Tenant.

Eine across.*-Anfrage kostet bis zu 64 Lesezugriffe auf den Speicher, aber das Ratenlimit (--rate-limit-rps) zählt sie nur einmal, für X-Tenant-ID: Datenbank und Limit entsprechend dimensionieren oder die Liste vom Proxy begrenzen lassen. Das Zugriffsprotokoll einer across.*-Route enthält die empfangene Liste (Feld read_tenants). Der Header gehört nicht zu den erlaubten CORS-Headern: Nur der Proxy schreibt ihn, nie ein Browser.

Sicherung und Wiederherstellung

Vier Handgriffe, je nachdem, was wiederhergestellt werden soll.

Alles, unter PostgreSQL — pg_dump ist das Werkzeug, und blunderDB hat dem nichts hinzuzufügen:

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

Alles, unter SQLite im Container — die Datei wird im WAL-Modus geöffnet (der Daemon kodiert journal_mode(WAL) in seiner Verbindungszeichenkette, für alle Verbindungen des Pools): neben blunderdb.db leben eine -wal- und eine -shm-Datei, und die jüngsten Schreibvorgänge stehen in der -wal-Datei. Nur die .db eines laufenden Daemons zu kopieren ergibt daher eine unvollständige Datei, ohne dass irgendetwas darauf hinweist. Zwei sichere Wege:

  • den Daemon stoppen und dann das gesamte Volume kopieren — im gestoppten Zustand sind die drei Dateien konsistent, und es ist das Volume, nicht die .db allein, das die zu sichernde Einheit ist;

  • die Datei überhaupt nicht kopieren: /v1/exports.sqlite (unten) schreibt eine vollständige .db, während der Daemon läuft, und das ist der einzige Handgriff, der keine Unterbrechung erfordert.

/ops/maintenance.vacuum faltet das WAL zwar in die Hauptdatei zurück, bevor es sie neu schreibt, friert die Datenbank dabei aber nicht ein: Der nächste Schreibvorgang beginnt wieder im WAL. Das ist ein Kompaktierungsbefehl, keine Sicherungsmethode.

Ein einzelner Mandant — /v1/exports.sqlite schreibt die Datenbank eines Mandanten in eine gewöhnliche .db-Datei, die die Desktop-Anwendung öffnet:

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

Dieser Befehl wird auf der Maschine des Daemons ausgeführt: Er zielt auf den lokalen Listener, umgeht den Proxy und setzt daher den Tenant-Header selbst. Von außen befragt man den Proxy, und der Tenant ist der des authentifizierten Kontos — der Header ist nicht anzugeben, der Proxy löscht den des Clients, bevor er seinen eigenen einfügt:

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

Diese Datei zurückspielen — migrate kopiert sie unter den gewünschten Mandanten:

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

migrate weigert sich, in einen Mandanten zu schreiben, der bereits etwas enthält, und sagt was („128 Stellungen, 3 Matches“); --on-conflict skip macht trotzdem weiter und lässt die Zobrist-Deduplizierung die Stellungen zusammenführen.

Was migrate nicht kopiert und am Ende mit der genauen Zahl meldet: die Anki-Stapel und ihre Karten, die Filterbibliothek, die Such- und Befehlsverläufe und den Sitzungszustand. Das sind Nutzungsdaten der Desktop-Anwendung; die Stellungen, auf die sie verweisen, sind sehr wohl mitgezogen.

Die Fehler- und Blunder-Schwellen hingegen werden kopiert: Sie sind keine Nutzungsdaten, sondern die Lesegewohnheit, von der die Zählungen abhängen, und ein Tenant, der anders zählen würde als die Datei, aus der er stammt, würde die Migration zu einer stummen Bedeutungsänderung machen.

Der Tenant stellt seine eigenen über POST /v1/librarySettings.load und /v1/librarySettings.save ein. Anders als metadata, das eine globale, nur lesend zugängliche Infrastruktur ist, trägt die Tabelle der Einstellungen eine tenant_id und lebt unter Row-Level Security: Ein Tenant, der seine Schwellenwerte schreibt, erreicht nichts als seine eigenen Zeilen.

Der Arbeitsplatz und der Server

Die Desktop-Anwendung öffnet Dateien .db, keine URLs: Sie verbindet sich mit keinem serve-Daemon, und es gibt nirgends ein Feld, um eine Adresse einzugeben. Der Server und der Arbeitsplatz tauschen Dateien aus, in zwei symmetrischen Handgriffen:

Es gibt kein tenantübergreifendes Lesen. Die Trennung ist vollständig: Nichts, was ein Tenant speichert, ist über irgendeine Route für einen anderen sichtbar, und kein Aufruf nimmt einen Tenant als Parameter entgegen — jede Anfrage kennt nur den, den der Proxy ihr mitgegeben hat. Ein Trainer, der die Matches seiner Schüler sehen möchte, hat also zwei Wege, beide ausdrücklich:

  • ihm im Proxy ein zusätzliches Konto einrichten, das dem Tenant des Schülers zugeordnet ist: Es ist die Zuordnungstabelle des Proxys, niemals der Daemon, die entscheidet, welchen Tenant eine Sitzung sieht;

  • ihn um einen Export bitten — die von exports.sqlite oder vom Exportfenster der Desktop-Anwendung erzeugte .db — und sie auf dem eigenen Arbeitsplatz öffnen.

Bereitstellung mit Docker

Das Repository stellt eine Dockerfile.serve bereit, die ein minimales Container-Image des Daemons erstellt: Nur das serve-Binary wird kompiliert (reines Go, ohne grafische Oberfläche und ohne CGO, also statisch gelinkt) und anschließend in ein distroless-Image gelegt.

# 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

Der Build wird von der Wurzel des Repositorys aus gestartet, und das Standard-Backend des Images ist postgres.

Das Image lauscht auf Port 8080 und wird über Umgebungsvariablen konfiguriert (BLUNDERDB_BACKEND, BLUNDERDB_DSN, BLUNDERDB_ADDR, BLUNDERDB_RLS). Es deklariert einen HEALTHCHECK, der alle 30 Sekunden blunderdb healthcheck ausführt (eine Anfrage an /readyz — das distroless-Image hat weder curl noch eine Shell): docker ps zeigt den Container als healthy oder unhealthy, und Compose oder ein Orchestrator können warten, bis der Daemon bereit ist, bevor sie starten, was von ihm abhängt.

Veröffentlichtes Image

Es ist nicht nötig, das Image selbst zu bauen: Jede veröffentlichte Version von blunderDB pusht ihr eigenes auf die GitHub-Registry (GHCR), unter dem Namen ghcr.io/kevung/blunderdb-serve. Zwei Tags sind verfügbar: die Versionsnummer, für immer auf diesem Image festgeschrieben, und latest, das der zuletzt veröffentlichten Version folgt. Die gesamte Dokumentation notiert sie als ghcr.io/kevung/blunderdb-serve:<version>: Die Nummer einer veröffentlichten Version tritt an die Stelle von <version>, und genau diese Form — niemals latest — pinnt ein Produktions-Deployment. Das Image wird für linux/amd64 und linux/arm64 bereitgestellt; Docker wählt die Architektur des Hosts.

# 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 ist der Mountpoint, den das Image mit den Rechten seines unprivilegierten Benutzers vorbereitet, samt seinem XDG_DATA_HOME: das dort eingehängte Volume dient nicht nur der Datenbank, die Bearoff-Tabellen werden dort einmalig in /data/blunderdb berechnet und bei den folgenden Starts wiedergefunden. Ohne Volume werden sie bei jedem Start des Containers neu berechnet — einige Sekunden —, und der Daemon meldet beim Start, wenn er sie nicht schreiben kann (could not prepare the bearoff tables; the exact regime will be unavailable); in diesem Fall bedient er normal weiter, nur mit dem geschätzten Regime für Auswürfelstellungen.

Das Image trägt die üblichen OCI-Labels (org.opencontainers.image.source, .version, .revision, .licenses): docker inspect verrät, aus welchem Commit und welcher Version es stammt. Es wird von der Continuous Integration aus der Dockerfile.serve des Repositorys gebaut, genau wie oben; ob man lokal baut oder das veröffentlichte Image zieht, ergibt dasselbe Binary.

Warnung

Wie der Daemon selbst führt der Container keinerlei Authentifizierung durch (ADR-0005): Er vertraut dem Header X-Tenant-ID so, wie er ihn empfängt. Er muss hinter einem Reverse-Proxy platziert werden, der für die Authentifizierung zuständig ist und diesen Header selbst setzt, und darf niemals direkt dem öffentlichen Internet ausgesetzt werden. Die obigen Beispiele veröffentlichen den Port nur aus diesem Grund auf 127.0.0.1, und --addr bindet sich ebenso an 127.0.0.1: der Proxy läuft auf derselben Maschine.

Bereitstellung hinter einem authentifizierenden Proxy

Der ADR-0005 macht den Reverse-Proxy zur gesamten Sicherheitsgrenze des Daemons: nur er authentifiziert den Aufrufer, nur er darf den Header X-Tenant-ID setzen, und er muss jeden vom Client gesendeten Wert konsequent entfernen, bevor er den authentifizierten Tenant einfügt — sonst kann sich jeder für einen beliebigen Tenant ausgeben, indem er ihn einfach benennt. Das Bedrohungsmodell lässt sich in einem Satz zusammenfassen: Der Daemon setzt ein vertrauenswürdiges internes Netzwerk voraus, und wer ihn direkt erreicht, ist für ihn der Tenant, für den er sich ausgibt. Das Repository liefert ein vollständiges, sofort startbares Beispiel im Verzeichnis deploy/. Es lebt im Git-Repository, nicht im Container-Image: Man muss also das Repository klonen oder die beiden unten wiedergegebenen Dateien sowie deploy/.env.example in dasselbe Verzeichnis herunterladen.

Die Compose-Datei setzt Caddy — HTTP-Basic-Authentifizierung zu Demonstrationszwecken — vor blunderdb-serve und PostgreSQL, mit aktivierter Row-Level Security. Nur Caddy veröffentlicht einen Port: Die beiden anderen Dienste leben in einem als internal: true deklarierten Docker-Netzwerk, das weder zum Host noch ins Internet routet, unabhängig davon, welche ports: eine spätere Änderung ihnen hinzufügen würde.

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

Die Caddyfile authentifiziert, ordnet das authentifizierte Konto der Ganzzahl des Tenants zu (map), und fügt sie dann nach dem ausdrücklichen Löschen jedes vom Client empfangenen Werts in X-Tenant-ID ein: Die Schutzklausel header_up X-Tenant-ID "" steht vor der Injektion, sodass ein vom Client gesendeter Header den Daemon nicht erreichen kann, unabhängig von späteren Änderungen der Datei.

Dasselbe gilt für X-Read-Tenants (Mehrere Tenants lesen): Der Proxy entfernt den Header des Clients und setzt ihn nur, wenn er die Beziehung zwischen den Konten kennt; die Beispiele des Repositorys kennen keine und entfernen ihn immer.

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
	}
}

Zwei weitere Dateien vervollständigen das Verzeichnis: deploy/nginx-tenant-proxy.conf übernimmt dasselbe Schema als nginx-Ausschnitt (proxy_set_header X-Tenant-ID "" gefolgt von proxy_set_header X-Tenant-ID $tenant_id, mit dem Block map $remote_user $tenant_id), für alle, die bereits einen nginx im Einsatz haben; deploy/README.md legt das Bedrohungsmodell dar und was niemals zu tun ist.

Die HTTP-Basic-Authentifizierung des Caddyfile ist eine Demonstration, keine Empfehlung für den Produktivbetrieb: Sie wird durch forward_auth zu einem echten Identitätsanbieter (OIDC, Unternehmens-SSO …) ersetzt, der authentifiziert und die Identität dann an derselben Stelle der Datei übergibt. Die beiden Passwörter und die beiden Konten der Zuordnungstabelle sind ebenso zu ersetzen.

deploy/Caddyfile.oidc ist das OpenID-Connect-Rezept: Caddy fragt oauth2-proxy ab (forward_auth auf /oauth2/auth), der mit 202 antwortet und die Adresse des angemeldeten Kontos in X-Auth-Request-Email mitliefert oder auf die Anmeldeseite des Anbieters weiterleitet. Der map-Block ordnet diese Adresse der Ganzzahl des Tenants zu, und dieselbe Schutzregel header_up X-Tenant-ID "" steht vor der Einspeisung. Der zur Compose-Datei hinzuzufügende Dienst oauth2-proxy steht am Anfang der Datei.

Quoten pro Tenant

Eine gemeinsam genutzte Instanz begrenzt mit --quota-positions, --quota-analysis-seconds und --quota-imports, was jeder Tenant ihr entnimmt (ohne Option wird nichts begrenzt). Die Rechenzeit zählt jede vom Tenant angeforderte Engine-Berechnung: gammonnet.analyzeMissing, gammonnet.sweepStale, gammonnet.compare, gammonnet.cubeMatrix, gammonnet.evaluate, rollout.position und rollout.filter. Sie wird in CPU-Sekunden gezählt: die verstrichene Zeit mal die Anzahl der gleichzeitig geführten Suchen, sodass eine auf alle Kerne verteilte Berechnung ebenso viel kostet wie dieselbe Arbeit, die Stellung für Stellung erledigt wird. Ist die Zeit des Tages aufgebraucht, antworten diese Routen mit 429 und dem Code quota_exceeded. Ein laufender Durchlauf oder ein laufendes rollout.filter behält, was es gespeichert hat, und endet mit dem Ereignis quota_exceeded statt done; ein unterbrochenes rollout.position antwortet mit 429 und speichert nichts; ein unterbrochener Vergleich liefert, was er zusammengeführt hat, mit quotaExceeded: true und in gathered die Anzahl der Stellungen, die er prüfen sollte. Der Zähler wird um Mitternacht UTC auf null gesetzt und liegt im Arbeitsspeicher: Ein Neustart des Daemons setzt ihn zurück. Die Positionsquote wird zu Beginn eines Imports geprüft, der unterwegs nicht abgebrochen wird: Ein Tenant kann sie um so viel überschreiten, wie seine laufenden Importe hinzufügen. positions.save und die anderen Einzelschreibvorgänge prüfen sie nicht. Jede Ablehnung enthält in details die Grenze (quota, limit) und die Nutzung (used). tenants.quota gibt dem aufrufenden Tenant seine Grenzen und seine Nutzung zurück: gespeicherte Positionen, Rechensekunden des Tages, laufende Importe.

Quoten sind eine Buchführung des Daemons, keine Grenze: Sie gelten für den Tenant, den der Proxy in X-Tenant-ID gesetzt hat.

Vollständiges Szenario, von null bis zu einem antwortenden Daemon:

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

Die erste Anfrage wird von Caddy abgelehnt, noch bevor sie den Daemon erreicht. Die beiden folgenden werden als „alice“ authentifiziert, die die Zuordnungstabelle dem Tenant 1 zuordnet: Sie liefern denselben Rumpf ({"positions":0,"analyses":0,"matches":0,…}) zurück, und das Protokoll des Daemons trägt für beide tenant=1 — der vom Client gesendete Wert 999 hat die Schutzklausel der Caddyfile nicht überlebt. Dieses Szenario wurde genau so nachgespielt.

Um das veröffentlichte Image zu ziehen, statt es zu bauen, ersetzen Sie in docker-compose.yml die drei build:-Zeilen des Dienstes blunderdb-serve durch eine image:-Zeile, und starten Sie dann docker compose up -d ohne --build:

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

Die Compose-Datei veröffentlicht den Port von Caddy auf allen Schnittstellen (8080:80): Das erwartet man von einem Proxy, der ja erreichbar sein soll. Was niemals veröffentlicht werden darf, ist der Daemon — und das ist er auch nicht, er hat keinerlei ports:.

Ein Deployment aktualisieren

Das Schema wird beim Start automatisch migriert, und diese Migration ist eine Einbahnstraße: Eine auf ein neueres Schema migrierte Datenbank ist von einer älteren Version von blunderDB nicht mehr lesbar (siehe Anhang: Datenbankschema). Die Reihenfolge der Schritte zählt daher.

  1. Zuerst sichern, vor allem anderen: Das ist der einzige Rückweg (siehe Sicherung und Wiederherstellung).

  2. Das Tag der gewünschten Version ziehen, im Produktivbetrieb niemals latest. latest folgt der zuletzt veröffentlichten Version: Ein Deployment, das es pinnt, wechselt bei jedem Neustart die Version, ohne dass dies entschieden wurde und ohne dass die Sicherung aus Schritt 1 zwangsläufig aktuell wäre.

  3. Den Daemon neu starten mit dem neuen Image. Er migriert das Schema, bevor er auch nur eine Anfrage bedient; schlägt die Migration fehl, hält er mit einem Fehler an, statt eine halb migrierte Datenbank zu bedienen.

  4. Die Bereitschaftssonde prüfen. GET /readyz antwortet mit 200 und {"status":"ready","version":"…"}, wenn der Speicher antwortet und sein Schema dem der Binary entspricht; mit 503 und {"status":"down"}, wenn die Datenbank unerreichbar ist; mit 503 und {"status":"version_mismatch","version":"…","expected":"…"}, wenn die beiden Schemas voneinander abweichen — die Antwort nennt das der Datenbank und das von der Binary erwartete. blunderdb healthcheck liefert dasselbe Urteil als Rückgabecode.

Ein version_mismatch, der nach dem Neustart bestehen bleibt, bedeutet einen Rückschritt: eine ältere Binary vor einer bereits migrierten Datenbank. Es gibt keine abwärtsgerichtete Migration; wiederherzustellen ist die Sicherung aus Schritt 1.

Wichtig

Bevor Sie --read-tenants auf einem bestehenden Deployment aktivieren, aktualisieren Sie den Proxy: Ein vor diesem Header konfigurierter Proxy entfernt nur X-Tenant-ID und würde einen vom Client gesendeten X-Read-Tenants unverändert weiterleiten, der dann andere Tenants läse. Ohne die Option lehnt der Daemon diesen Header ab: Ein Proxy, der ihn durchlässt, fällt an seinen 400-Antworten auf.

PostgreSQL-Backend und Mehrbenutzerbetrieb

Für eine gemeinsame Bereitstellung kann blunderDB die Daten in PostgreSQL statt in einer SQLite-Datei speichern. Das Backend wird über --backend postgres und die Verbindungszeichenkette --dsn ausgewählt. Das Schema wird beim Start automatisch erstellt und migriert.

Die Daten sind nach Tenant (Mandant) getrennt: Jede Anfrage trägt die Kennung ihres Tenants (Header X-Tenant-ID, eine positive Dezimal-Ganzzahl wie 1 oder 42), sodass mehrere Benutzer dieselbe Instanz teilen können, ohne die Daten der anderen zu sehen. Eine Kennung, die keine solche Ganzzahl ist — ein Name wie alice oder default, 0, 007 — wird mit 400 invalid abgewiesen: Der Reverse-Proxy ordnet ein Konto seiner Ganzzahl zu, der Daemon rät nie.

Row-Level Security

Die Option --rls aktiviert zusätzlich die Row-Level Security von PostgreSQL. Bei jedem Start installiert der Daemon auf jeder Tabelle mit einem tenant_id eine Richtlinie tenant_isolation, die nur die Zeilen des durch den Sitzungsparameter current_setting('app.tenant_id') benannten Tenants durchlässt, und erzwingt sie bis zum Eigentümer der Tabelle (FORCE ROW LEVEL SECURITY). Dieser Parameter wird beim Herausgeben der Verbindung aus dem Pool gesetzt und bei ihrer Rückgabe zurückgesetzt; eine Verbindung ohne Tenant sieht keine Zeile und fügt keine ein. Dies ist eine optionale, standardmäßig deaktivierte Verteidigung in der Tiefe: Die tenantbezogene Filterung im Anwendungscode bleibt in beiden Fällen bestehen.

  • Die Verbindungsrolle muss gewöhnlich sein: weder Superuser noch BYPASSRLS. PostgreSQL lässt diese beiden anstandslos jede Richtlinie durchqueren, und die Isolation wird wieder allein zur Sache des Anwendungscodes. Dieselbe Rolle muss die Tabellen jedoch besitzen, da sie die ALTER TABLE- und CREATE POLICY-Anweisungen ausführt.

  • Auf einer bereits gefüllten Datenbank gibt es nichts zu migrieren: Das Setzen der Richtlinien ist idempotentes DDL, das bei jedem Start nach der Schema-Migration erneut ausgeführt wird. Keine Daten werden verschoben, keine Zeile umgeschrieben; --rls zu aktivieren oder zu entfernen ist nur ein Neustart.

  • Die Kosten sind gemessen: beim Lesen einer Stellung 101,8 µs ohne, 177,0 µs mit, also +73,8 % — derselbe Container, dieselben Zeilen, zwei Pools, die sich nur durch dieses Flag unterscheiden. Sie fallen bei jedem Ausleihen einer Verbindung aus dem Pool an (Setzen und anschließendes Zurücksetzen des Parameters) und beim zusätzlichen Prädikat, das jede Anfrage durchläuft, niemals beim Datenvolumen.

Einen Tenant eröffnen und schließen

Serverseitig gibt es nichts anzulegen: Ein Tenant ist kein Datensatz, sondern die Ganzzahl, die seine Zeilen tragen. Die Datenbank hat keine Tenant-Tabelle, und der Daemon führt keine Liste davon — ein Konto zu eröffnen bedeutet, einen Eintrag in der Zuordnungstabelle des Proxys hinzuzufügen, und der erste Schreibvorgang des Mitglieds lässt seinen Tenant entstehen.

Ein leerer Tenant antwortet wie eine leere Datenbank, ohne Fehler: metadata.counts liefert Nullen zurück, und die Listen liefern nichts.

Wird ein Tenant außer Betrieb genommen, löscht POST /ops/tenant.purge unwiderruflich alle seine Daten (Stellungen, Matches, Sammlungen, Verlauf usw.) für den aktuellen Tenant (den über X-Tenant-ID übermittelten), sowie seinen Sitzungszustand (letzte Suche, letzte Stellung, geöffnete Tabs — die Zeilen der Tabelle session_state, die diesen Tenant tragen): Der Vorgang läuft in einer einzigen Transaktion ab, ist idempotent (kein Fehler beim Purgen eines bereits leeren Tenants oder beim erneuten Aufruf) und wirkt sich auf keinen anderen Tenant aus. Er löscht die Zeilen dieses Tenants in allen Tabellen, die einen solchen tragen, und lässt nur übrig, was niemandem gehört: die Tabelle metadata samt ihrer globalen Schema-Versionszeile, und das Migrationsprotokoll. Der geleerte Tenant wird also genau zu einem leeren Tenant, und seine Ganzzahl wird wieder frei vergeben. Er ist nur mit dem PostgreSQL-Backend verfügbar — bei einem SQLite-Backend, das keinen Begriff von Tenant kennt, liefert er einen invalid-Fehler.

Kompaktierung und Verbindungspool

POST /ops/maintenance.vacuum komprimiert die SQLite-Datei des Daemons — das Gegenstück zur Schaltfläche „Datenbank komprimieren“ der grafischen Oberfläche und zum Befehl blunderdb vacuum (siehe Befehlszeilenschnittstelle (CLI)), mit derselben Speicherplatzprüfung — und liefert die Größen davor und danach zurück (sizeBefore, sizeAfter, in Bytes). Sie ist nur mit dem SQLite-Backend verfügbar; bei PostgreSQL, das keine zu komprimierende Datei besitzt, liefert sie einen Fehler invalid.

Der PostgreSQL-Verbindungspool wird über Umgebungsvariablen eingestellt: BLUNDERDB_POSTGRES_MAX_CONNS (Standard 50), BLUNDERDB_POSTGRES_MIN_CONNS (5), BLUNDERDB_POSTGRES_MAX_CONN_LIFETIME (1h), BLUNDERDB_POSTGRES_HEALTH_CHECK_PERIOD (30s), BLUNDERDB_POSTGRES_CONNECT_TIMEOUT (5s — darüber hinaus schlägt eine nicht erreichbare Datenbank schnell fehl, statt am TCP-Timeout des Betriebssystems hängen zu bleiben) und BLUNDERDB_POSTGRES_MAX_CONN_IDLE_TIME (30m — eine für einen Verkehrsschub geöffnete Verbindung bleibt nach dessen Ende nicht endlos im Pool). Jeder Wert ist eine Dauer im Go-Format (5s, 30m, 1h); fehlt er oder ist er ungültig, gilt der Standardwert. Wenn --metrics aktiv ist, wird der Zustand des Pools laufend auf /metrics bereitgestellt: blunderdb_pg_pool_acquired (derzeit genutzte Verbindungen), _idle (verfügbare), _max (konfigurierte Obergrenze) und _wait_count (kumulierte Zahl der Acquire-Aufrufe, die auf eine freie Verbindung warten mussten).

Eine SQLite-Datenbank nach PostgreSQL migrieren

blunderdb migrate kopiert eine Einzelbenutzer-SQLite-Datenbank in ein PostgreSQL-Backend, unter einem gewählten Tenant — der Ganzzahl, die der Reverse-Proxy für diesen Benutzer in X-Tenant-ID senden wird — das ist der Weg, um eine Desktop-Bibliothek in eine Server-Bereitstellung „hochzuladen“.

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

Die Migration kopiert die Stellungen, ihre Analysen und Kommentare, die Matches (Partien + Züge), die Turniere (mit ihren Match-Verknüpfungen) und die Sammlungen (mit ihrer Zusammensetzung), wobei die Primär- und Fremdschlüssel neu zugewiesen werden, das Ganze in einer einzigen Transaktion auf der Zielseite: Der Vorgang ist atomar (ein Fehlschlag lässt das Ziel unverändert, es genügt, ihn erneut zu starten). Der Fortschritt und die abschließende Bilanz werden als NDJSON auf der Standardausgabe ausgegeben. Ist die Quelldatenbank alt genug, um selbst eine Schema-Aktualisierung an Ort und Stelle zu benötigen, läuft diese zuerst und gibt ihre eigenen "schema-migration"-Ereignisse (Phase/erledigt/gesamt) aus, bevor das zeilenweise Kopieren beginnt.

Option

Standard

Bedeutung

--from <uri>

–

SQLite-Quelldatenbank (sqlite:///<Pfad> oder ein einfacher Pfad)

--to <dsn>

–

Ziel-PostgreSQL-DSN (postgres://…)

--tenant-id <n>

–

Ziel-Tenant, eine positive Dezimal-Ganzzahl (erforderlich außer bei --dry-run; ein Name wie mon-tenant wird abgewiesen)

--dry-run

–

zählt, was kopiert würde, ohne etwas zu schreiben

--on-conflict <Richtlinie>

""

"" bricht ab, wenn der Tenant bereits Daten hat; skip führt zusammen (Deduplizierung der Stellungen über den Zobrist-Hash)

Bemerkung

Noch nicht migriert werden die Anwendungszustände: Anki-Decks/-Karten, Filterbibliothek, Such- und Befehlsverlauf sowie Sitzungsmetadaten. Vorrang hat die Migration der Stellungsbibliothek und des Match-Verlaufs.

Der generische Dispatcher call

Ergänzend zu den herkömmlichen Unterbefehlen (Befehlszeilenschnittstelle (CLI)) stellt blunderdb call alle Speicheroperationen direkt und lokal bereit. Es verwendet dieselben Handler wie der Daemon serve: das Verhalten ist also identisch zu POST /v1/<famille>.<méthode>. Das ist nützlich für Skripting und Integrationstests.

# --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"}'

Optionen:

Option

Standard

Bedeutung

--db <Pfad>

–

SQLite-Datei (Kurzform für --backend sqlite --dsn <chemin>)

--backend <type>

sqlite

sqlite oder postgres

--dsn <Zeichenkette>

$BLUNDERDB_DSN

Verbindungszeichenkette des Backends

--scope <n>

1

Tenant, eine positive Dezimal-Ganzzahl (als X-Tenant-ID gesendet; ein Name wie alice wird abgewiesen)

--json <Zeichenkette>

{}

Anfragerumpf im JSON-Format

--json-file <Pfad>

–

liest den Anfragerumpf aus einer Datei

--list

–

zeigt alle Methoden <famille>.<méthode> an und beendet sich

--if-match <version>

–

in If-Match gesendete Version, verlangt von den Leitungsgesten (Die Leitungsgesten) und den Transkriptionsgesten (Transkribieren über die API)

call bedient die Transkriptionszüge ohne Flag: es arbeitet auf einer lokalen Datei, wie die CLI. Jeder Aufruf ist ein neuer Prozess und damit eine eigene Sitzung: die sessionId kann weggelassen werden, und es gibt kein Rückgängigmachen von einem Aufruf zum nächsten.

Die JSON-Antwort (oder der NDJSON-Strom bei den *.list-Endpunkten) wird auf die Standardausgabe geschrieben. Im Fehlerfall endet der Prozess mit einem von null verschiedenen Code, und der Umschlag {"error":{…}} wird auf die Standardausgabe geschrieben, damit er analysierbar bleibt (zum Beispiel mit jq). Eine Antwort mit dem Header Direction-Version gibt ihn auf der Standardfehlerausgabe aus: Das ist der Wert, den die nächste Geste an --if-match übergibt. call bedient die Gesten der Turnierleitung ohne Schalter, wie die CLI, da es lokal läuft.

Werkzeuge für einen KI-Assistenten (MCP)

blunderDB bringt kein Sprachmodell mit: Es stellt seine Werkzeuge dem Assistenten zur Verfügung, den Sie bereits nutzen (Claude Code, Claude Desktop, ein lokaler Client), über das Model Context Protocol. Der Assistent sucht, liest und erklärt; blunderDB antwortet mit seinen eigenen Zahlen.

Die Werkzeuge laufen über dieselben Handler wie /v1 und call:

Werkzeug

Was es liefert

database_overview

Zählungen, Zeitraum der Matches, Schemaversion, häufige Spieler

search_positions

Positionen einer Suche in der Grammatik der Befehlsleiste (im Werkzeug beschrieben), mit ihrer kanonischen Form

search_comments, saved_searches

Kommentare, die bestimmte Wörter enthalten; gespeicherte Suchen

get_position

eine Position, ihre Analyse (beste Züge oder Doppler), der gespielte Zug und der Kommentar

explain_error

das Thema des Fehlers, seine Kosten in Millipunkten und die beste Entscheidung

similar_positions, decode_position, legal_moves, race_epc

benachbarte Positionen; Lesen einer XGID; legale Züge; Rennen-EPC

list_players, player_stats, recurring_errors, training_stats

Spieler; globale PR, Steine, Doppelwürfel, nach Phase; wiederkehrende Fehler; Quiz-PR und Anki-Retention gegenüber der echten PR

list_matches, get_match, list_tournaments

Matches, Detail eines Matches, Turniere

list_collections, collection_positions, study_decks

Sammlungen und ihre Positionen; Lernstapel

quiz_draw, quiz_grade

zieht eine Position ohne ihre Antwort und bewertet dann die gegebene Antwort

evaluate

gammonNet-Bewertung einer als Text angegebenen Stellung, ohne sie zu speichern: beste Züge oder Doppelentscheidung

anki_next

die nächste fällige Karte eines Wiederholungsstapels

transcribe_list, transcribe_get, transcribe_mat

Match-Transkriptionen; Detail einer Transkription; ihr .mat-Text

direction_list, direction_standings, direction_season

geleitete Turniere; Rangliste eines Turniers; Saisonwertung

rollout

Rollout einer Stellung der Datenbank: Equity, 95-%-Intervall und JSD je Kandidat

Nur fünf Werkzeuge schreiben — save_position, comment_position, create_collection, add_to_collection und anki_review, das eine von anki_next gezogene Karte bewertet — und werden nur auf Anfrage angeboten: --write lokal, --mcp-write auf dem Daemon. Alle anderen lesen nur; rollout erhält jedoch, wenn Schreiben angeboten wird, das Argument store, das den Rollout neben der Analyse der Position speichert. Kein Werkzeug löscht etwas.

Lokal startet der Assistent blunderdb mcp auf einer Datei (siehe Befehlszeilenschnittstelle (CLI)). Für Claude Code:

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

Am Daemon antworten dieselben Werkzeuge per HTTP auf POST /mcp (Streamable-HTTP-Transport, ohne Sitzung). Wie /v1 verlangt /mcp X-Tenant-ID, und jedes Werkzeug arbeitet in diesem Tenant; ein Programm, das pkg/blunderdb/server einbettet, stellt es ebenfalls bereit. Der Daemon authentifiziert niemanden (ADR-0005): /mcp wird wie /v1 am Proxy geschützt, und --mcp-write wird dort wie --direction entschieden. Jeder /v1-Aufruf eines Werkzeugs durchläuft erneut die gesamte Kette des Daemons: Er wird protokolliert, in den Metriken gezählt und dem Ratenlimit des Mandanten angerechnet, zusätzlich zur /mcp-Anfrage, die ihn trägt. Ein Werkzeugaufruf kostet also mehrere Anfragen; keine ist davon ausgenommen.

Wie call migriert blunderdb mcp das Schema einer älteren Datenbank beim Öffnen, auch ohne --write.