Λειτουργία χωρίς γραφικό περιβάλλον (διακομιστής)

Σημείωση

Αυτή η ενότητα περιγράφει μια προηγμένη και προαιρετική λειτουργία του blunderDB, που προορίζεται για αναπτύξεις σε διακομιστή, για πολλαπλούς χρήστες και για αυτοματοποίηση. Η συνήθης και συνιστώμενη χρήση του blunderDB παραμένει η εφαρμογή γραφείου που περιγράφεται στα προηγούμενα κεφάλαια. Αν χρησιμοποιείτε το blunderDB μόνοι σας, στον υπολογιστή σας, δεν χρειάζεστε αυτή τη λειτουργία: μπορείτε να αγνοήσετε αυτό το κεφάλαιο χωρίς να χάσετε καμία από τις λειτουργίες ανάλυσης.

Επισκόπηση

Το ίδιο εκτελέσιμο blunderdb μπορεί, εκτός από την εφαρμογή γραφείου και τις εντολές γραμμής εντολών (δείτε Διεπαφή γραμμής εντολών (CLI)), να λειτουργήσει σε λειτουργία χωρίς γραφικό περιβάλλον: χωρίς γραφική διεπαφή, ελεγχόμενο εξ ολοκλήρου μέσω της γραμμής εντολών ή μέσω δικτύου. Αυτή η λειτουργία συγκεντρώνει τρεις χρήσεις:

  • ο δαίμονας serve — εκθέτει τη μηχανή του blunderDB ως υπηρεσία HTTP + JSON, για να τρέχει μια κοινόχρηστη βάση σε διακομιστή και να την προσπελάζουν πολλοί χρήστες·

  • ο γενικός dispatcher call — καλεί οποιαδήποτε λειτουργία αποθήκευσης απευθείας, τοπικά, για scripting και δοκιμές·

  • η εντολή migrate — μεταφέρει μια βάση SQLite ενός χρήστη προς ένα backend PostgreSQL πολλαπλών χρηστών.

Αυτές οι τρεις χρήσεις βασίζονται σε ένα κοινό επίπεδο αποθήκευσης που γνωρίζει να επικοινωνεί με δύο backend: το SQLite (η συνήθης μορφή αρχείου .db της εφαρμογής γραφείου) και το PostgreSQL (για αναπτύξεις διακομιστή πολλαπλών χρηστών).

Ο δαίμονας serve

Το blunderdb serve εκκινεί τη μηχανή ως υπηρεσία HTTP που απαντά σε JSON. Επιτρέπει τη φιλοξενία μιας βάσης θέσεων σε ένα μηχάνημα και την προσπέλασή της από πολλούς πελάτες.

# 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

Σημείωση

Το sslmode=disable ταιριάζει μόνο σε ένα έμπιστο ιδιωτικό δίκτυο — μια βάση σε γειτονικό container, σε δίκτυο που δεν έχει διαδρομή ούτε προς τον host ούτε προς το Internet. Για μια απομακρυσμένη βάση, το sslmode=require κρυπτογραφεί τη σύνδεση και το verify-full ελέγχει επιπλέον το πιστοποιητικό του διακομιστή και το όνομα host του. Οι υπόλοιπες συμβολοσειρές σύνδεσης αυτής της σελίδας φέρουν sslmode=disable για τον ίδιο λόγο: περιγράφουν όλες ένα ιδιωτικό δίκτυο.

Προειδοποίηση

Ο δαίμονας δεν εκτελεί καμία πιστοποίηση ταυτότητας. Εμπιστεύεται την κεφαλίδα αιτήματος X-Tenant-ID και πρέπει να τρέχει πίσω από έναν reverse-proxy (nginx, Caddy…) υπεύθυνο για την πιστοποίηση ταυτότητας. Μην τον εκθέτετε ποτέ απευθείας στο δημόσιο Διαδίκτυο.

Το X-Tenant-ID είναι ο ακέραιος του tenant (1, 2, 42…): είναι δουλειά του reverse-proxy να αντιστοιχίσει τον πιστοποιημένο λογαριασμό σε αυτόν τον ακέραιο. Ένα όνομα (alice) απορρίπτεται με 400 invalid, ποτέ δεν μετατρέπεται.

Επιλογές:

Επιλογή

Προεπιλογή

Σημασία

--db <chemin>

–

αρχείο SQLite (συντόμευση για --backend sqlite --dsn <chemin>)

--backend <type>

sqlite

backend αποθήκευσης: sqlite ή postgres

--dsn <chaîne>

$BLUNDERDB_DSN

συμβολοσειρά σύνδεσης του backend

--addr <hôte:port>

:8080

διεύθυνση ακρόασης

--log-level <niveau>

info

επίπεδο καταγραφής: debug|info|warn|error

--metrics

true

εκθέτει το /metrics (μορφή Prometheus)

--web

false

σερβίρει τη σελίδα ιστού για ανάγνωση στο /app/· απενεργοποιημένη εξ ορισμού, δείτε παρακάτω

--direction

false

εξυπηρετεί τις ενέργειες διεύθυνσης τουρνουά και διοργάνωσης· απενεργοποιημένες από προεπιλογή, βλ. Οι ενέργειες διεύθυνσης

--mcp-write

false

προσφέρει τα εργαλεία εγγραφής του /mcp· απενεργοποιημένα από προεπιλογή, βλ. Εργαλεία για έναν βοηθό ΤΝ (MCP)

--transcription

false

εξυπηρετεί τις κινήσεις μεταγραφής (transcriptions.create, apply, finish…)· απενεργοποιημένο από προεπιλογή, βλ. Μεταγραφή μέσω του API

--transcription-ttl <διάρκεια>

30m

κλείνει μια συνεδρία μεταγραφής που είναι αδρανής για περισσότερο από αυτό το διάστημα

--cors-allow-origin <origine>

–

ενεργοποιεί το CORS για αυτήν την προέλευση, μια λίστα προελεύσεων χωρισμένων με κόμμα, ή * (απενεργοποιημένο από προεπιλογή)· η απόκριση αντικατοπτρίζει την προέλευση του αιτήματος μόνο αν αυτή περιλαμβάνεται στη λίστα, με Vary: Origin

--rate-limit-rps <n>

50

όριο αιτημάτων ανά δευτερόλεπτο και ανά tenant (0 = απενεργοποιημένο)· ενεργοποιημένο εξ ορισμού σε μια γενναιόδωρη τιμή αντί να είναι προαιρετικό, ώστε ένα αρχείο compose που σκέφτεται μόνο τη βάση δεδομένων να μην κληρονομήσει έναν δαίμονα χωρίς κανένα όριο

--rate-limit-burst <n>

100

μέγεθος του κάδου διακριτικών (token bucket) για τις αιχμές αιτημάτων

--quota-positions <n>

0

θέσεις που μπορεί να αποθηκεύσει ένας tenant, με έλεγχο στην αρχή μιας εισαγωγής: μόλις επιτευχθεί το όριο, η εισαγωγή απορρίπτεται (413, storage_quota_exceeded)· το positions.save και οι άλλες μεμονωμένες εγγραφές δεν περιορίζονται· 0 = απεριόριστο

--quota-analysis-seconds <n>

0

δευτερόλεπτα CPU υπολογισμού της μηχανής ανά tenant και ανά ημέρα UTC (429, quota_exceeded)· 0 = απεριόριστο

--quota-imports <n>

0

εισαγωγές του ίδιου tenant που εκτελούνται ταυτόχρονα (429, quota_exceeded)· 0 = απεριόριστο

--rls

false

PostgreSQL: ενεργοποιεί το Row-Level Security ανά tenant (άμυνα σε βάθος, προαιρετική)

--read-tenants

false

τιμά την κεφαλίδα X-Read-Tenants στις αναγνώσεις across.*· όταν είναι απενεργοποιημένη, η κεφαλίδα απορρίπτεται (400) — βλ. Ανάγνωση πολλών tenants

--bearoff-ts <fichier>

–

προαιρετική αμφίπλευρη βάση bearoff (.bd) που διευρύνει τον πίνακα TS-06-06 για την ανάλυση κούρσας του σημείου πρόσβασης EPC· ο δαίμονας δεν κατεβάζει ποτέ βάση — δείτε Οι βάσεις bearoff

--identity-dir <répertoire>

–

κατάλογος της ταυτότητας υπογραφής του δαίμονα (δημιουργείται από μόνη της στην πρώτη χρήση)· απαραίτητος για να μπορεί η exports.sqlite να επιθέτει σήμανση — βλ. παρακάτω

--ops-addr <κόμβος:θύρα>

–

εξυπηρετεί την οικογένεια /ops/ (maintenance.vacuum, tenant.purge) σε διεύθυνση ξεχωριστή από την --addr και την αφαιρεί από εκείνη· κενό (η προεπιλογή) τις αφήνει στον κύριο ακροατή, όπου η άρνηση του προθέματος είναι δουλειά του proxy — βλ. Οι διαδρομές λειτουργίας

--pprof-addr <κόμβος:θύρα>

–

εκθέτει το net/http/pprof σε διεύθυνση ξεχωριστή από την --addr (απενεργοποιημένο από προεπιλογή)· μόνο για αποσφαλμάτωση — τα σημεία αυτά δεν έχουν καμία έννοια tenant και δίνουν προφίλ μνήμης ή CPU ολόκληρης της διεργασίας· ποτέ δημόσια ούτε στην ίδια διεύθυνση με το /v1

Οι περισσότερες επιλογές μπορούν να δοθούν και μέσω μεταβλητής περιβάλλοντος (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): μια ρητή σημαία υπερισχύει της αντίστοιχης μεταβλητής.

Ο δαίμονας δεν διαθέτει επιλογή φακέλου δεδομένων: γράφει τους πίνακες bearoff του στο $XDG_DATA_HOME/blunderdb, ή ελλείψει αυτού στο ~/.local/share/blunderdb. Είναι λοιπόν το XDG_DATA_HOME που τους μετακινεί — δείτε Οι βάσεις bearoff.

Ο ίδιος ο πίνακας κάδων του περιοριστή ρυθμού φέρει ένα σκληρό ανώτατο όριο (10.000 διακριτά tenants): πέρα από αυτό, κάθε νέο tenant εκτοπίζει τον λιγότερο πρόσφατα χρησιμοποιημένο κάδο αντί να αφήσει τον πίνακα να μεγαλώνει απεριόριστα — χρήσιμο αν ένας πελάτης στέλνει πολλές διακριτές τιμές X-Tenant-ID, εκούσια ή μη, ανάμεσα σε δύο περιοδικούς καθαρισμούς ανενεργών κάδων.

Το blunderdb serve απορρίπτει πλέον κάθε απρόσμενο θεσιακό όρισμα (πέρα από το μοναδικό αρχικό serve που αφήνει να περάσει ένα ENTRYPOINT ήδη περιορισμένο στο γυμνό εκτελέσιμο): χωρίς αυτόν τον έλεγχο, μια σημαία τοποθετημένη μετά από τέτοιο όρισμα αγνοούνταν σιωπηλά — το docker run image serve --addr :9090, φυσική αντανακλαστική κίνηση αφού το ENTRYPOINT της εικόνας είναι ήδη serve, ξεκινούσε στο :8080 χωρίς καμία προειδοποίηση.

Σημεία πρόσβασης

Η υπηρεσία εκθέτει σημεία πρόσβασης λειτουργίας, πάντα διαθέσιμα:

  • GET /healthz — ζωτικότητα (η διεργασία τρέχει)·

  • GET /readyz — διαθεσιμότητα (η αποθήκευση απαντά και το σχήμα της είναι στην αναμενόμενη έκδοση)·

  • GET /metrics — μετρικές Prometheus (αν το --metrics είναι ενεργό)·

  • GET /app/ — η σελίδα ιστού για ανάγνωση (αν το --web είναι ενεργό).

Η σελίδα ιστού

Το blunderdb serve --web σερβίρει μια σελίδα στο /app/: μια βιβλιοθήκη που συμβουλεύεσαι από tablet ή τηλέφωνο, χωρίς να εγκαταστήσεις τίποτα.

Ξέρει να κάνει τρία πράγματα, και αυτή η λίστα είναι η απόφαση, όχι ένα στάδιο:

  • να συμβουλεύεται μια θέση, την ανάλυσή της και το ταμπλό της·

  • να αναζητά, με την ίδια γραμματική συμβόλων που έχει η γραμμή εντολών της εφαρμογής·

  • να επαναλαμβάνει μια τράπουλα Anki — με αποκάλυψη απάντησης και βαθμό.

Δεν ξέρει να επεξεργάζεται θέση, να εισάγει, να διαγράφει, να διαχειρίζεται συλλογές, αγώνες, τουρνουά ή ρυθμίσεις, και δεν θα μάθει. Μια λειτουργία που λείπει εδώ δεν είναι κενό: είναι το εύρος.

Είναι απενεργοποιημένη εξ ορισμού, και αυτό το εξ ορισμού είναι η απόφαση. Ο δαίμονας δεν πιστοποιεί κανέναν: εμπιστεύεται την κεφαλίδα X-Tenant-ID και πρέπει να τρέχει πίσω από μεσολαβητή που πιστοποιεί. Το να παραδοθεί μια διεπαφή προσβάσιμη από φυλλομετρητή, ανοιχτή εξ αρχής, θα προσκαλούσε ακριβώς την ανάπτυξη που αυτός ο κανόνας απαγορεύει.

Η σελίδα δεν στέλνει tenant: ο μεσολαβητής βάζει την κεφαλίδα, όπως για κάθε άλλον πελάτη. Στην τοπική ανάπτυξη, και μόνο εκεί, το /app/?tenant=1 ονομάζει έναν — πράγμα που δεν αλλάζει τίποτα στην ασφάλεια ενός δαίμονα που ήδη δέχεται αυτή την κεφαλίδα από οποιονδήποτε.

Τα αρχεία της σελίδας σερβίρονται χωρίς tenant, εσκεμμένα: ένας φυλλομετρητής πρέπει να μπορεί να φορτώσει τη σελίδα πριν του αποδώσει ο μεσολαβητής οτιδήποτε, και μια σελίδα δεν περιέχει δεδομένα.

Η ζωτικότητα και η διαθεσιμότητα απαντούν σε δύο διαφορετικές ερωτήσεις. Το /healthz απαντά πάντα 200 μόλις η διεργασία εξυπηρετεί αιτήματα, χωρίς ποτέ να ρωτά την αποθήκευση: ένας ενορχηστρωτής επανεκκινεί το container του οποίου η ζωτικότητα αποτυγχάνει, και μια προσωρινά μη προσβάσιμη βάση δεν πρέπει να επανεκκινεί σε βρόχο έναν υγιή δαίμονα. Το /readyz απαντά 503 (με status ίσο με down ή version_mismatch) όσο η βάση δεν απαντά ή το σχήμα της δεν είναι αυτό του δυαδικού: η κίνηση απλώς εκτρέπεται μέχρι να επιστρέψει.

Η υποεντολή blunderdb healthcheck (παρούσα και στο δυαδικό serve της εικόνας container) εκτελεί ένα αίτημα GET /readyz στον τοπικό δαίμονα και επιστρέφει 0 αν είναι διαθέσιμος, 1 αλλιώς· η διεύθυνση είναι αυτή του --addr ή του BLUNDERDB_ADDR, από προεπιλογή :8080. Είναι το HEALTHCHECK της εικόνας Docker και είναι εξίσου χρήσιμη σε ένα script ή σε μια μονάδα systemd:

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

Η επιχειρησιακή επιφάνεια ακολουθεί το σχήμα POST /v1/<οικογένεια>.<μέθοδος> (για παράδειγμα /v1/positions.save, /v1/matches.get). Οι οικογένειες καλύπτουν θέσεις, αναλύσεις, αγώνες, σχόλια, συλλογές, τουρνουά, κάρτες Anki, φίλτρα, συνεδρίες, ιστορικό (αναζήτησης και εντολών), αναζήτηση, μεταδεδομένα, ρυθμίσεις βιβλιοθήκης, στατιστικά, εισαγωγή και εξαγωγή. Τα σημεία καταλόγου επιστρέφουν ροή NDJSON (ένα αντικείμενο JSON ανά γραμμή). Ο διακομιστής τερματίζει καθαρά με SIGINT / SIGTERM.

Ένα σφάλμα επιστρέφει το περίβλημα {"error":{"code":…,"message":…}}. Ο κωδικός not_found λέει ότι ένας ονομασμένος πόρος δεν υπάρχει· ο unknown_route, επίσης 404, λέει ότι ο δαίμονας δεν εξυπηρετεί τη μέθοδο που κλήθηκε: πελάτης και δαίμονας διαφορετικών εκδόσεων, ή μια οικογένεια που ο δαίμονας εξυπηρετεί μόνο με μια σημαία. Ένας πελάτης συμπεραίνει ότι λείπουν δεδομένα μόνο με not_found.

Το positions.save επιστρέφει {"id":…,"created":…}. Το created είναι true μόνο για την κλήση που εισήγαγε τη θέση, και το λέει η ίδια η εγγραφή: ένας πελάτης που αντιγράφει μια θέση και μετά την ανάλυσή της, και πρέπει να αναιρέσει την αντιγραφή μετά από αποτυχία, διαγράφει τη θέση μόνο αν τη δημιούργησε, χωρίς τη συνθήκη ανταγωνισμού ενός προηγούμενου positions.exists.

Τι υπόσχεται το /v1

Ένας πελάτης γραμμένος για το /v1 πρέπει να συνεχίσει να λειτουργεί. Ο κανόνας χωράει σε τρεις γραμμές, και είναι πιο χρήσιμος γραμμένος παρά μαντεμένος:

  • Ό,τι υπάρχει δεν αλλάζει νόημα. Μια διαδρομή του /v1 δεν μετονομάζεται, δεν αφαιρείται, δεν επανανοηματοδοτείται. Ένα πεδίο αιτήματος ή απόκρισης δεν μετονομάζεται, δεν αφαιρείται, δεν αλλάζει τύπο.

  • Ό,τι προστίθεται, προστίθεται. Μια νέα διαδρομή, ένα προαιρετικό πεδίο αιτήματος, ένα νέο πεδίο σε μια απόκριση: ένας πελάτης που τα αγνοεί συνεχίζει να λειτουργεί — αυτός είναι ο ορισμός του «συμβατού» που υιοθετείται εδώ. Ένας πελάτης πρέπει λοιπόν να αγνοεί τα πεδία που δεν γνωρίζει αντί να τα απορρίπτει.

  • Τα υπόλοιπα είναι /v2. Το να γίνει υποχρεωτικό ένα πεδίο που δεν ήταν, να αλλάξει μια μονάδα, να αλλάξει η σημασία ενός κωδικού σφάλματος: αυτά είναι ρήξεις, και ζουν κάτω από άλλο πρόθεμα, δίπλα στο /v1, όσο χρειάζεται για να περάσουν οι πελάτες.

Δύο διευκρινίσεις που μετρούν. Οι διαδρομές /ops/ δεν καλύπτονται: εξυπηρετούν τη λειτουργία μιας εγκατάστασης, αλλάζουν μαζί της, και δεν είναι API για προγράμματα τρίτων. Και το ίδιο το συμβόλαιο παράγεται από τον πίνακα διαδρομών του δαίμονα (openapi.yaml, Σύμβαση API): δεν μπορεί να περιγράψει κάτι άλλο από αυτό που σερβίρει ο διακομιστής.

Μεταγραφή μέσω του API

Η οικογένεια transcriptions.* επιτρέπει σε έναν εξωτερικό πελάτη να μεταγράψει έναν αγώνα κίνηση προς κίνηση, με την ίδια λογική με την επιφάνεια εργασίας. Οι αναγνώσεις (list, get, exportMat, losses) εξυπηρετούνται πάντα. Οι κινήσεις (create, open, editMatch, apply, undo, redo, close, finish, abandon) εξυπηρετούνται μόνο με serve --transcription: χωρίς αυτή τη σημαία, οι διαδρομές αυτές απαντούν 404.

Τα create και open επιστρέφουν την κατάσταση του προσχεδίου, την revision του και ένα sessionId. Τα apply, undo, redo, close και finish κατονομάζουν αυτό το sessionId: απόν → 400, ληγμένη ή άγνωστη συνεδρία → 410· ο πελάτης ξανανοίγει τότε το προσχέδιο (open), με τον δρομέα στο τέλος του εγγράφου. Το abandon δεν κατονομάζει συνεδρία: διαγράφει το προσχέδιο μόνο υπό την αναθεώρηση του If-Match. Κάθε κίνηση που γράφει φέρει την τελευταία αναθεώρηση που είδε στην κεφαλίδα If-Match και επιστρέφει την επόμενη:

  • απούσα If-Match → 428·

  • παλιά αναθεώρηση → 409· ο φάκελος σφάλματος δίνει την τρέχουσα αναθεώρηση (details.revision) και τη φρέσκια κατάσταση του προσχεδίου (details.state: έγγραφο, αναθεώρηση, συνεδρία και δρομέας), που ο πελάτης εμφανίζει πριν επαναλάβει την κίνησή του αν ισχύει ακόμη.

Η αναθεώρηση προχωρά μόνο όταν αλλάζει το έγγραφο (κεφαλίδα και ενέργειες): η μετακίνηση του δρομέα ή η εισαγωγή ενός ζαριού της τρέχουσας ενέργειας δεν γράφει τίποτα και επιστρέφει την ίδια αναθεώρηση. Μια συνεδρία ανήκει στο προσχέδιο, όχι σε έναν πελάτη: το open επιστρέφει τη ζωντανή συνεδρία όταν υπάρχει, και οι καρτέλες ή οι σταθμοί που τη μοιράζονται μοιράζονται επίσης τον δρομέα και τη στοίβα αναίρεσης.

Η συνεδρία κρατά μόνο τη στοίβα αναίρεσης, τον δρομέα και την εισαγωγή σε εξέλιξη: το προσχέδιο γράφεται μετά από κάθε κίνηση που το αλλάζει, ώστε μια χαμένη συνεδρία (αδράνεια, επανεκκίνηση, άλλο στιγμιότυπο) να μη χάνει καμία κίνηση. Το transcriptions.get επιστρέφει την αναθεώρηση ως ETag και απαντά 304 σε ένα If-None-Match που την κατονομάζει.

Το finish καταχωρεί τον αγώνα και διαγράφει το προσχέδιο, το abandon το διαγράφει χωρίς αγώνα, το close απελευθερώνει μόνο τη συνεδρία. Το editMatch ανοίγει ένα προσχέδιο σε έναν υπάρχοντα αγώνα και, για έναν εισαγόμενο αγώνα, επιστρέφει το πλήθος των αναλύσεων και των σχολίων που η μεταγραφή δεν διατηρεί (losses.lossy). Η ανάλυση του αποθηκευμένου αγώνα ξεκινά με gammonnet.analyzeMissing.

Προειδοποίηση

Ο δαίμονας δεν πιστοποιεί κανέναν: το να ανοίξει η εγγραφή σημαίνει να την εμπιστευτείτε στον proxy (Ανάπτυξη πίσω από έναν proxy πιστοποίησης ταυτότητας). Ένας ρόλος «μεταγραφέας» είναι κανόνας του proxy στο πρόθεμα /v1/transcriptions., όχι έννοια του δαίμονα.

Ένας πελάτης Python

Ο κατάλογος clients/python/ περιέχει έναν ελάχιστο πελάτη, χωρίς εξάρτηση εκτός της πρότυπης βιβλιοθήκης — ο δαίμονας μιλά POST και JSON, που τα urllib και json καλύπτουν πλήρως:

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

Είναι σε δύο μισά, και είναι σκόπιμο. Το _generated.py φέρει μία μέθοδο ανά διαδρομή, παραγόμενη από τον πίνακα διαδρομών του δαίμονα με go run ./cmd/openapi-gen: μια χειρόγραφη επιφάνεια θα παρέκκλινε τη μέρα που προστίθεται μια διαδρομή, και κανείς δεν θα το πρόσεχε πριν το κάνει ένας χρήστης. Το client.py φέρει τη μεταφορά — τη συνεδρία, την κεφαλίδα tenant, τον φάκελο σφάλματος, την ανάγνωση NDJSON — και είναι γραμμένο στο χέρι. Ό,τι αλλάζει με το API παράγεται· ό,τι αλλάζει με την κρίση, όχι.

Τα ονόματα μεθόδων είναι οικογένεια_λειτουργία σε snake_case: το /v1/positions.loadByIds γίνεται positions_load_by_ids(). Η οικογένεια διατηρείται επειδή πολλές οικογένειες μοιράζονται ένα όνομα λειτουργίας (list, delete), και ένα σκέτο list() θα συγκρουόταν.

Η events() ακολουθεί το /v1/events και επιστρέφει ένα λεξικό ανά μήνυμα (βλ. Ειδοποίηση για τις ενέργειες: /v1/events).

Μια αποτυχία εγείρει APIError, που φέρει τον φάκελο του δαίμονα ως έχει: τον code (αυτό πάνω στο οποίο διακλαδίζεται ένα πρόγραμμα), το message (αυτό που διαβάζει ένας άνθρωπος), το HTTP status και τις λεπτομέρειες.

Ενσωμάτωση της μηχανής σε πρόγραμμα Go

Το pkg/blunderdb/server.Bootstrap ανοίγει την αποθήκευση και επιστρέφει ένα σύνολο χειριστών μέσα στη διεργασία που καλεί, χωρίς να ακούει σε θύρα. Είναι η είσοδος για έναν έμπιστο γονέα — το gammonGo — που θέλει τη βιβλιοθήκη θέσεων χωρίς να τρέχει έναν δαίμονα δίπλα ούτε να μιλά HTTP στον εαυτό του.

Αυτό που προϋποτίθεται λέγεται ρητά: ο γονέας είναι έμπιστος. Δεν υπάρχει tenant να ελεγχθεί, ούτε κεφαλίδα να επικυρωθεί, ούτε περιοριστής ρυθμού — αυτά ανήκουν στον δαίμονα επειδή αντικρίζει δίκτυο, και το ADR-0005 λέει γιατί. Ένα πρόγραμμα που ενσωματώνει τη μηχανή επιλέγει μόνο του το tenant του και απαντά για τις κλήσεις του.

Διεύθυνση τουρνουά και διοργανώσεις

Τα τουρνουά που διευθύνονται στον σταθμό εργασίας και οι διοργανώσεις που τα ομαδοποιούν (rencontre στο API και στις διαδρομές του /v1/rencontres.*) διαβάζονται μέσω του API, υπό το tenant του καλούντος, με τον ίδιο κώδικα με τον σταθμό εργασίας. Η ανάγνωση εξυπηρετείται πάντα· οι ενέργειες (καταχώριση αποτελέσματος, αντιστοίχιση, δημιουργία διοργάνωσης) εξυπηρετούνται μόνο με serve --direction (Οι ενέργειες διεύθυνσης).

  • Οι directions.list και directions.directory διαβάζουν ολόκληρο το tenant: τη λίστα των τουρνουά που διευθύνονται, τον κατάλογο των παικτών.

  • Οι υπόλοιπες directions.* δέχονται {"tournamentId": N}: directions.get (η πλήρης προβολή: προτάσεις, κατάταξη, αγώνες σε εξέλιξη), directions.participants, directions.freeParticipants, directions.tableGrid, directions.brackets, directions.standings, directions.standingsCsv, directions.history (προαιρετικά φίλτρα player και match), directions.clock, directions.slots, directions.lastDecision, directions.pageHtml και directions.pairingSheetHtml (με round).

  • rencontres.list, έπειτα rencontres.get και rencontres.pageHtml με {"id": N}. Η rencontres.pageHtml αποδίδει την επιτοίχια σελίδα της αίθουσας, ένα αυτόνομο έγγραφο HTML στο πεδίο html: μια επιτοίχια οθόνη το εμφανίζει και το ξαναδιαβάζει περιοδικά.

  • Το rencontres.ranking επιστρέφει την κατάταξη σεζόν, όπως το blunderdb tournament ranking --season: rencontreId, from, to, points, participation και elo, όλα προαιρετικά· χωρίς rencontreId ούτε περίοδο, μετρούν όλα τα διευθυνόμενα τουρνουά του tenant.

Οι σελίδες αποδίδονται στα γαλλικά, τη γλώσσα της μηχανής διεύθυνσης. Ένα τουρνουά που δεν διευθύνεται, ή που ανήκει σε άλλο tenant, απαντά 404.

Υπό συνθήκη αναγνώσεις. Κάθε μία από αυτές τις διαδρομές επιστρέφει κεφαλίδα ETag. Αν σταλεί πίσω στο If-None-Match, λαμβάνει 304 χωρίς σώμα εφόσον δεν έχει αλλάξει τίποτα από όσα διαβάζει η διαδρομή. Κάθε εγγραφή αλλάζει αμέσως το ETag: μια κίνηση στο τουρνουά ή σε τουρνουά της ίδιας διοργάνωσης, η σύνδεση ενός αγώνα, ένα προσχέδιο που ξεκίνησε από μια θέση, η μετονομασία ενός τουρνουά, η τροποποίηση της διοργάνωσης. Η απάντηση 304 δεν επαναλαμβάνει κανένα τουρνουά, γεγονός που κάνει φθηνή μια επιτοίχια σελίδα που ρωτά κάθε λίγα δευτερόλεπτα. Μόνο ό,τι εξαρτάται από την ώρα αποτελεί εξαίρεση: οι προτάσεις, το ρολόι και οι σελίδες υπολογίζονται τη στιγμή της ανάγνωσης, και ένα ETag ισχύει επομένως το πολύ ένα λεπτό. Έτσι, ένας πελάτης που ξαναδιαβάζει βλέπει μια προθεσμία ή μια παύση να περνά μέσα στο λεπτό.

Αυτές οι διαδρομές είναι POST. Για αυτό το ρήμα, το RFC 9110 (§13.1.2) απαντά 412 σε ένα If-None-Match που επαληθεύεται. Ο δαίμονας απαντά παρ” όλα αυτά 304: το σώμα του αιτήματος περιέχει μόνο τις παραμέτρους μιας ανάγνωσης χωρίς παρενέργειες, που συμπεριφέρεται σαν GET. Η μορφή If-None-Match: * απορρίπτεται (400), επειδή δεν υποδεικνύει καμία απάντηση που ο πελάτης θα είχε ήδη. Ένα μη έγκυρο αίτημα (ένα αρνητικό round, για παράδειγμα) απορρίπτεται πριν από οποιαδήποτε συνθήκη.

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

Όπως και το υπόλοιπο /v1, αυτές οι διαδρομές δεν ταυτοποιούν κανέναν: πίσω από το proxy (Ανάπτυξη πίσω από έναν proxy πιστοποίησης ταυτότητας), όποιος φτάνει στο πρόθεμα /v1/directions. ενός tenant διαβάζει τα τουρνουά του, μαζί με τα ονόματα των παικτών. Ένα proxy που επιφυλάσσει αυτές τις αναγνώσεις σε ορισμένους χρήστες το κάνει με κανόνα σε αυτό το πρόθεμα και στο /v1/rencontres..

Οι ενέργειες διεύθυνσης

Το blunderdb serve --direction ανοίγει τις ενέργειες που κάνει ο σταθμός εργασίας σε ένα διευθυνόμενο τουρνουά και σε μια διοργάνωση. Χωρίς αυτή τη σημαία, οι διαδρομές αυτές απαντούν 404, σαν να μην υπάρχουν. Το call τις εξυπηρετεί πάντα.

  • directions.create (tournamentId, config, seed), directions.setConfig και directions.previewConfig (config, η διαμόρφωση σε μορφή JSON της μηχανής)·

  • οι εγγραφές: directions.enterParticipants (players), directions.addParticipant (name, club, rating· με section και key, ένας καθυστερημένος παίρνει θέση απαλλαγής), directions.updateParticipant, directions.withdraw, directions.reinstate, directions.makeAbsent, directions.makeAvailable, directions.addPair, directions.updatePair·

  • η εξέλιξη: directions.confirmProposal (action, όπως την προτείνει το directions.get), directions.confirmAllProposals, directions.startMatch, directions.enterResult, directions.enterForfeit, directions.moveMatchToTable, directions.cancelMatch, directions.correctResult, directions.close, directions.reopen, directions.addNote, directions.attachMatch, directions.detachMatch·

  • η διοργάνωση: rencontres.create, rencontres.update, rencontres.attach, rencontres.detach, rencontres.trash, rencontres.setTableOutOfService, rencontres.setBreaks;

  • οι ιδιότητες των τραπεζιών: rencontres.setTables (id, tableSettings, μία εγγραφή ανά τραπέζι που έχει κάποια: αριθμός, όνομα, αίθουσα, δεσμευμένο, ορισμένο για), rencontres.setEventRooms (id, tournamentId, rooms, οι αίθουσες όπου παίζεται το αγώνισμα· καμία σημαίνει όλα τα τραπέζια) και directions.setTables (tournamentId, tableSettings) για ένα αγώνισμα που παίζεται μόνο του.

Μια ενέργεια τουρνουά επιστρέφει την πλήρη προβολή του τουρνουά, όπως το directions.get· μια ενέργεια διοργάνωσης επιστρέφει τη διοργάνωση. Στη συνέχεια η υπηρεσία ξαναγράφει τις σελίδες προβολής στον φάκελο που υποδεικνύει η βάση, όπως στον σταθμό εργασίας. Μια σελίδα που δεν μπορεί να γραφτεί (φάκελος που χάθηκε, γεμάτος δίσκος) δεν ακυρώνει την ενέργεια: η απόκριση φέρει μια κεφαλίδα Direction-Page-Warning ανά σελίδα που δεν γράφτηκε (tournament 3, rencontre 2), χωρίς τη διαδρομή του διακομιστή, και ο σταθμός εργασίας την εμφανίζει στη γραμμή κατάστασής του.

Μια ενέργεια που οι κανόνες απορρίπτουν (κενό όνομα, κατειλημμένο τραπέζι, τουρνουά που δεν έχει ξεκινήσει, διαμόρφωση που απορρίφθηκε από τη μηχανή) επιστρέφει 400 με το σκεπτικό. Μια βλάβη του δαίμονα ή της βάσης του επιστρέφει 500, χωρίς λεπτομέρειες: το σκεπτικό παραμένει στο αρχείο καταγραφής του δαίμονα.

Υποχρεωτική έκδοση. Κάθε ανάγνωση τουρνουά ή διοργάνωσης επιστρέφει κεφαλίδα Direction-Version, και κάθε ενέργεια την στέλνει πίσω στο If-Match:

  • χωρίς If-Match (ή με *), η ενέργεια απορρίπτεται: 428·

  • αν κάποιος έχει γράψει από εκείνη την ανάγνωση, η ενέργεια απορρίπτεται: 409. Το πεδίο details του σφάλματος φέρει την τρέχουσα κατάσταση και την version της: ο πελάτης διαβάζει ξανά και επαναλαμβάνει την ενέργειά του αν εξακολουθεί να ισχύει·

  • διαφορετικά η ενέργεια εφαρμόζεται και επιστρέφει τη νέα έκδοση στο Direction-Version.

Η σύγκριση γίνεται μέσα στη συναλλαγή της ενέργειας, υπό κλείδωμα της βάσης (συμβουλευτικό κλείδωμα PostgreSQL ανά τουρνουά ή ανά διοργάνωση, κλείδωμα εγγραφής SQLite): από δύο ενέργειες που στάλθηκαν πάνω στην ίδια ανάγνωση, εφαρμόζεται μόνο η μία, είτε περνούν από τον ίδιο δαίμονα, από δύο δαίμονες σε μία ίδια βάση PostgreSQL, είτε από τον σταθμό εργασίας και το call σε ένα ίδιο αρχείο. Η ενέργεια γράφεται εξ ολοκλήρου ή καθόλου. Ένα τουρνουά που παίζεται σε μια διοργάνωση έχει την έκδοση της διοργάνωσής του, ώστε μια ενέργεια σε ένα αδελφό αγώνισμα να την αλλάζει κι αυτήν. Τα directions.create και rencontres.create δεν στοχεύουν τίποτα υπάρχον και δεν παίρνουν έκδοση.

Ιδεμποτεντικότητα. Μια ενέργεια που φέρει κεφαλίδα Idempotency-Key εφαρμόζεται μία μόνο φορά: όταν ξαναστέλνεται με το ίδιο κλειδί, επιστρέφει την πρώτη απόκριση, με τις κεφαλίδες της (συμπεριλαμβανομένης της Direction-Version) και Idempotency-Replayed: true. Ένα διπλό κλικ ή μια επανάληψη του δικτύου δεν καταχωρεί δύο αποτελέσματα· δύο ταυτόχρονες αποστολές του ίδιου κλειδιού εκτελούν την ενέργεια μία μόνο φορά. Διατηρείται μόνο μια επιτυχής απόκριση.

  • Το κλειδί συνδέεται με το σώμα του αιτήματος: το ίδιο κλειδί με άλλο σώμα επιστρέφει 422.

  • Η επανάληψη προηγείται του ελέγχου έκδοσης: επιστρέφει τη διατηρημένη απόκριση χωρίς 428 ή 409, ακόμη και αν η έκδοση έχει αλλάξει στο μεταξύ.

  • Τα κλειδιά ζουν στη μνήμη, σε κάθε στιγμιότυπο του δαίμονα, για 24 ώρες, το πολύ 1 000 ανά tenant: μια επανεκκίνηση τα ξεχνά, και ένα άλλο στιγμιότυπο δεν τα γνωρίζει.

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

Προειδοποίηση

Ο δαίμονας δεν πιστοποιεί κανέναν (ADR-0005). Με το --direction, όποιον αφήνει το proxy να περάσει καταχωρίζει αποτελέσματα. Η μηχανή δεν γνωρίζει κανέναν ρόλο (διευθυντής, διαιτητής, αναγνώστης): ο ρόλος είναι κανόνας του proxy, που επιφυλάσσει τα /v1/directions. και /v1/rencontres. στους διευθυντές ή αφήνει να περάσουν μόνο οι αναγνώσεις. Μην εκκινείτε ποτέ το --direction σε προσβάσιμο δαίμονα χωρίς αυτό το proxy, ακόμη και στο Wi-Fi ενός συλλόγου.

Ειδοποίηση για τις ενέργειες: /v1/events

Το GET /v1/events είναι ροή Server-Sent Events (text/event-stream): ένα μήνυμα ανά επικυρωμένη ενέργεια του tenant, δημοσιευμένο μετά την εγγραφή στη βάση, ποτέ για απορριφθείσα ή ακυρωμένη ενέργεια. Το μήνυμα λέει τι άλλαξε και τη νέα του έκδοση, όχι την κατάσταση: ο πελάτης διαβάζει ξανά ό,τι εμφανίζει, με If-None-Match.

  • event: rencontre — rencontreId, tournamentIds (τα αγωνίσματα της διοργάνωσης, πριν και μετά την ενέργεια) και version·

  • event: direction — tournamentId και version, για τουρνουά που παίζεται εκτός οποιασδήποτε διοργάνωσης·

  • event: transcription — transcriptionId και revision· ένα εγκαταλελειμμένο ή ολοκληρωμένο πρόχειρο φέρει removed (και matchId για την Ολοκλήρωση).

Το removed: true σηματοδοτεί ό,τι δεν υπάρχει πια. Η διαδρομή εξυπηρετείται μόνο με --direction ή --transcription: χωρίς αυτά, ο δαίμονας δεν γράφει τίποτα που θα έπρεπε να ανακοινώσει, και το /v1/events απαντά 404. Όπως κάθε διαδρομή /v1/, απαιτεί X-Tenant-ID: ένας συνδρομητής ακούει μόνο το δικό του tenant. Ένα tenant διατηρεί το πολύ 16 ανοιχτές ροές ταυτόχρονα· πέρα από αυτό, 429. Ο σταθμός εργασίας χρησιμοποιεί την ίδια υπηρεσία αλλά δεν συνδέει σε αυτήν κανένα bus: οι ενέργειές του δεν ανακοινώνονται.

Οι παράμετροι tournament, rencontre και transcription (αναγνωριστικά χωρισμένα με κόμματα ή επαναλαμβανόμενα) περιορίζουν τη συνδρομή: ένα μήνυμα περνά αν κατονομάζει κάποιο από αυτά. Ένα τουρνουά μιας διοργάνωσης λαμβάνει τα μηνύματα της διοργάνωσής του. Μια άγνωστη παράμετρος ή ένα μη έγκυρο αναγνωριστικό επιστρέφει 400.

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

Χωρίς ιστορικό. Ο δαίμονας δεν κρατά κανένα μήνυμα. Κάθε ροή ανοίγει με event: resync, με ένα id: ο πελάτης μπορεί να έχασε ενέργειες πριν συνδεθεί ή ανάμεσα σε δύο συνδέσεις, και διαβάζει ξανά ό,τι εμφανίζει. Ο λόγος είναι reconnected όταν το αίτημα φέρει Last-Event-ID, αλλιώς subscribed. Ένας υπερβολικά αργός συνδρομητής, του οποίου η ουρά των 64 μηνυμάτων είναι γεμάτη, αποσυνδέεται μετά από το ίδιο resync: δεν καθυστερεί ποτέ μια ενέργεια. Η ροή ανακοινώνει καθυστέρηση επανασύνδεσης 3 δευτερολέπτων.

Μέσω proxy. Ένα σχόλιο : ping στέλνεται κάθε 25 δευτερόλεπτα ώστε ένας proxy να μην κόβει μια σιωπηλή ροή· το X-Accel-Buffering: no ζητά από το nginx να μην την αποθηκεύει προσωρινά. Η ροή δεν συμπιέζεται, ξεφεύγει από το χρονικό όριο των συνηθισμένων αιτημάτων και μετρά ως ένα μόνο αίτημα στον περιορισμό ρυθμού. Ο τερματισμός του δαίμονα κλείνει όλες τις ροές· μια συνδρομή που ζητείται κατά τον τερματισμό λαμβάνει 503.

Πολλαπλές παρουσίες. Στο SQLite, μία μόνο παρουσία κατέχει τη βάση: ο δίαυλος στη μνήμη αρκεί. Στο PostgreSQL, μόλις ενεργοποιηθεί το --direction ή το --transcription, κάθε παρουσία αναμεταδίδει τις ενέργειές της στις άλλες μέσω LISTEN/NOTIFY, στο κανάλι blunderdb_events: ένας συνδρομητής συνδεδεμένος σε μια παρουσία ακούει μια ενέργεια που επικυρώθηκε σε άλλη, ή που έγινε μέσω call στην ίδια βάση. Ο tenant μεταφέρεται μέσα στην ειδοποίηση, και η παρουσία που τη λαμβάνει την παραδίδει μόνο στους συνδρομητές αυτού του tenant. Κάθε παρουσία ανοίγει δύο επιπλέον συνδέσεις (application_name blunderdb-events-… για την ακρόαση, blunderdb-notify-… για την αποστολή). Μια παρουσία που δεν μπορεί να ακούσει κατά την εκκίνηση αρνείται να ξεκινήσει. Το call ανακοινώνει χωρίς να ακούει, και εξυπηρετεί το αίτημά του ακόμη κι αν δεν μπορεί να ανακοινώσει.

Κάθε ρόλος που επιτρέπεται να συνδεθεί μπορεί να εκπέμψει σε αυτό το κανάλι, και με --rls. Μια ειδοποίηση που λαμβάνεται θεωρείται αξιόπιστη μόνο αν ο tenant της είναι έγκυρος και το είδος της γνωστό· τα υπόλοιπα καταγράφονται στο ημερολόγιο και αγνοούνται. Μια πλαστή ειδοποίηση μπορεί στη χειρότερη περίπτωση να κάνει τους συνδρομητές ενός tenant να ξαναδιαβάσουν τα δεδομένα τους.

  • Η ειδοποίηση αποστέλλεται μετά την εγγραφή στη βάση, όπως και το τοπικό μήνυμα. Δύο απώλειες παραμένουν χωρίς resync: μια παρουσία που τερματίζεται βίαια μεταξύ της εγγραφής και της ειδοποίησης, και ένας τερματισμός που δεν μπορεί να στείλει σε 2 δευτερόλεπτα ό,τι απομένει στην ουρά. Η ενέργεια είναι επικυρωμένη, αλλά οι ροές που είναι ήδη ανοιχτές στις άλλες παρουσίες το μαθαίνουν μόνο όταν επανασυνδεθεί ο πελάτης τους.

  • Μια χαμένη σύνδεση ακρόασης αποκαθίσταται, με αυξανόμενη αναμονή από 250 ms έως 30 s. Οι ενέργειες άλλων παρουσιών που έγιναν κατά τη διακοπή χάνονται: κατά την επαναφορά, κάθε συνδρομητής της παρουσίας λαμβάνει ένα resync με αιτία missed. Μια ειδοποίηση πολύ μεγάλη για το PostgreSQL (8 000 byte), ή που μια παρουσία δεν μπόρεσε να στείλει, φτάνει στις άλλες ως το ίδιο resync για τον ενδιαφερόμενο tenant.

  • Τα id της ροής είναι ιδιαίτερα για κάθε παρουσία. Ένας πελάτης που ένας εξισορροπητής φορτίου στέλνει σε άλλη παρουσία δεν ωφελείται από αυτά: το resync που ανοίγει κάθε ροή τον κάνει να ξαναδιαβάσει ό,τι εμφανίζει.

Οι βάσεις bearoff

Ο δαίμονας υπολογίζει τους δύο προεπιλεγμένους πίνακές του κατά την εκκίνηση, στο παρασκήνιο (TS-06-06 για την ετυμηγορία κύβου, OS-06 για το EPC): περίπου έξι δευτερόλεπτα ενός πυρήνα, μία φορά, στον φάκελο δεδομένων του — $XDG_DATA_HOME/blunderdb, ή ελλείψει αυτού ~/.local/share/blunderdb. Τίποτα δεν κατεβαίνει και τίποτα δεν είναι ενσωματωμένο στο εκτελέσιμο (ADR-0027). Αν ο φάκελος είναι μόνο για ανάγνωση, οι πίνακες κρατιούνται στη μνήμη για τη διάρκεια ζωής της διεργασίας: η υπηρεσία ξεκινά, απλώς πληρώνει τον υπολογισμό σε κάθε επανεκκίνηση.

Ένας ευρύτερος τομέας δεν υπολογίζεται στην εκκίνηση — ο TS-06-11 ζυγίζει 1,2 GB και θέλει λεπτά, δεν είναι κάτι που αποφασίζει μόνη της μια υπηρεσία. Είναι έργο του διαχειριστή να τον φτιάξει, με τη γραμμή εντολών, στον τόμο που θα διαβάσει ο δαίμονας:

# 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

Η πρώτη εκκίνηση αφήνει τον δαίμονα να βρει μόνος του τον πίνακα στον φάκελο δεδομένων του· η δεύτερη τον ορίζει με τη διαδρομή του, όπου κι αν βρίσκεται. Το --data-dir είναι επιλογή των υποεντολών bearoff, ποτέ του serve.

Το blunderdb bearoff list --data-dir /srv/data/blunderdb λέει τι περιέχει ο τόμος και τι θα κόστιζε κάθε τομέας· το blunderdb bearoff verify τερματίζει με σφάλμα σε κατεστραμμένο πίνακα, πράγμα που το κάνει έλεγχο εκκίνησης χρησιμοποιήσιμο ως έχει. Δείτε Διεπαφή γραμμής εντολών (CLI) για λεπτομέρειες.

Οι διαδρομές λειτουργίας

Δύο κλήσεις δεν σταματούν στον tenant που τις κάνει και ζουν επομένως κάτω από δικό τους πρόθεμα, POST /ops/<οικογένεια>.<μέθοδος>:

  • /ops/maintenance.vacuum (backend SQLite) ξαναγράφει ολόκληρο το αρχείο, μαζί με τα δεδομένα όλων των tenants, και κρατά κλείδωμα εγγραφής για όσο διαρκεί·

  • /ops/tenant.purge (backend PostgreSQL) καταστρέφει τα δεδομένα ενός tenant, και ο tenant που καταστρέφεται είναι αυτός που ονομάζει η κεφαλίδα την οποία ελέγχει ο καλών.

Ο δαίμονας δεν πιστοποιεί κανέναν (βλ. παρακάτω): μια διαδρομή προσβάσιμη από έναν tenant είναι διαδρομή που κάθε tenant μπορεί να καλέσει. Το πρόθεμα υπάρχει ώστε ο proxy να μπορεί να αρνηθεί και τις δύο με έναν μόνο κανόνα. Ποτέ μην εκθέτετε το /ops/ μέσω του δημόσιου proxy. Στο nginx, ο κανόνας χωράει σε μία γραμμή του μπλοκ server· στο Caddy, σε δύο γραμμές του site:

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

Η επιλογή --ops-addr <κόμβος:θύρα> πηγαίνει παραπέρα: οι δύο διαδρομές εγκαταλείπουν τότε τη διεύθυνση --addr και εξυπηρετούνται μόνο σε αυτόν τον δεύτερο ακροατή, που πρέπει να δεθεί σε διεπαφή διαχείρισης. Χωρίς την επιλογή παραμένουν στον κύριο ακροατή και ο αποκλεισμός τους είναι δουλειά του proxy.

Οι διαδρομές αυτές απαιτούν την κεφαλίδα X-Tenant-ID όπως όλες οι άλλες — μια εκκαθάριση ονομάζει τον tenant που καταστρέφει και τη χρειάζεται περισσότερο από οποιαδήποτε άλλη. Μόνο οι ανιχνευτές (/healthz, /readyz) και το /metrics την παρακάμπτουν.

Γι” αυτό ο παραπάνω κανόνας άρνησης καλύπτει και το /metrics: καθώς δεν απαιτεί κανένα tenant, είναι αναγνώσιμο από οποιονδήποτε φτάνει στον δαίμονα, και δημοσιεύει το μέγεθος της βάσης και την εργασία σε εξέλιξη, για όλους τους tenants μαζί. Συμβουλεύεται από το μηχάνημα του δαίμονα, ή μέσω μιας διαδρομής που ο proxy κρατά για τη λειτουργία. Το τρίτο σημείο που δεν πρέπει ποτέ να εκτεθεί δεν είναι διαδρομή αλλά ακροατής: αυτός του --pprof-addr, που δεν έχει καμία έννοια tenant και παραδίδει ένα προφίλ ολόκληρης της διεργασίας. Δένεται σε μια διεπαφή διαχείρισης, ποτέ δεν δημοσιεύεται από τον proxy.

Τι δεν πέρασε στο /ops/: το /v1/gammonnet.sweepStale. Η ανάκτηση είναι δαπανηρή αλλά περιορίζεται στον καλούντα tenant· αυτό που τη φράζει είναι το όριο ρυθμού και οι δείκτες εργασίας εν εξελίξει, όχι ένα όριο εμπιστοσύνης.

Το πλήρες συμβόλαιο — κάθε μέθοδος, το αίτημά της και η απόκρισή της — παράγεται από τον πηγαίο κώδικα και τηρείται σε έκδοση: openapi.yaml στη ρίζα του αποθετηρίου (μορφή OpenAPI, μαζί με τα σχήματα) και το ευανάγνωστο παράρτημά του, Σύμβαση API (ένας πίνακας ανά οικογένεια). Και τα δύο αναπαράγονται με go run ./cmd/openapi-gen και ένα ειδικό τεστ αποτυγχάνει αν κάποιο από τα δύο μείνει πίσω από τις πραγματικά καταχωρημένες διαδρομές.

Κάθε αίτημα /v1 δέχεται σώμα JSON (Content-Type: application/json, ή καμία κεφαλίδα — ένα σώμα άλλου τύπου απορρίπτεται με 400 invalid αντί να αποτυγχάνει με ένα μπερδεμένο μήνυμα σφάλματος ανάλυσης JSON)· μια γνωστή μέθοδος που καλείται με λάθος ρήμα HTTP απαντά 405, με την κεφαλίδα Allow να ονομάζει το μοναδικό αποδεκτό ρήμα. Οι μέθοδοι λίστας που δέχονται limit απορρίπτουν τιμές πέραν των 1000 γραμμών ανά σελίδα (400 invalid) αντί να τις τιμούν χωρίς όριο.

Όλες οι οικογένειες που παράγουν λίστα δέχονται limit και offset: positions.list, positions.listIds, matches.list, search.find, anki.reviewLog, comments.listAll, tournaments.list και collections.positions. Και τα δύο έχουν προεπιλογή μηδέν, που σημαίνει ό,τι σήμαινε πάντα: τα πάντα. Δεν υπάρχει σιωπηρό όριο — μια ροή δεν κρατιέται στη μνήμη, οπότε μια λίστα χωρίς όριο κοστίζει χρόνο και εύρος ζώνης αλλά ποτέ την ισορροπία του δαίμονα, ενώ ένα σιωπηλό προεπιλεγμένο όριο θα έκανε έναν πελάτη να διαβάσει μια κομμένη λίστα νομίζοντάς την πλήρη. Αυτό που προσφέρουν οι δύο παράμετροι είναι η δυνατότητα σελιδοποίησης, σε όποιον τη θέλει.

Κάθε σύνδεση TCP έχει όριο χρόνου ανάγνωσης/εγγραφής ανά αίτημα — ένα γενναιόδωρο περιθώριο για τις συνήθεις κλήσεις, πολύ μεγαλύτερο για τις διαδρομές ροής (λίστες NDJSON, εισαγωγές/εξαγωγές, το σάρωμα ανάκτησης gammonNet) — και ο αριθμός τους ταυτόχρονα ανοιχτών είναι πεπερασμένος: πέραν αυτού, μια επιπλέον σύνδεση περιμένει να ελευθερωθεί μία από τις υπάρχουσες αντί κάθε σύνδεση να λαμβάνει άνευ όρους το δικό της νήμα εκτέλεσης. Ένας ομαλός τερματισμός (SIGINT/SIGTERM) ακυρώνει πρώτα κάθε εισαγωγή και κάθε σάρωμα ανάκτησης gammonNet σε εξέλιξη — καθεμία απαντά με ένα τελικό συμβάν {"event":"cancelled"} αντί η σύνδεσή της να διακοπεί χωρίς εξήγηση — πριν το κλείσιμο του διακομιστή εντός του συνήθους χρόνου χάριτος. Το προσωρινό αρχείο μιας μεταφορτωμένης εισαγωγής διατηρεί από την αρχική επέκταση μόνο όσες αναγνωρίζει ο δαίμονας (.xg, .xgp, .sgf, .mat, .bgf, .ogxm, .txt, .db, .dbx), και όλες οι ταυτόχρονες εισαγωγές — σε όλους τους ενοικιαστές — μοιράζονται ένα καθολικό όριο byte που αποθηκεύονται στον δίσκο: πέραν αυτού, μία νέα εισαγωγή απορρίπτεται (too many requests) αντί να αφήνεται η χρήση του $TMPDIR να μεγαλώνει απεριόριστα.

Το /v1/imports.json διαβάζει ξανά μια εξαγωγή JSON του blunderDB συμπληρώνοντας κενά: η ανάλυση που φέρει γράφεται μόνο σε θέση που δεν έχει ακόμη, χωρίς ποτέ να αντικαθιστά υπάρχουσα ανάλυση, και τα rollouts και των δύο πλευρών διατηρούνται.

Η οικογένεια search προσφέρει τρεις πόρτες στην ίδια αναζήτηση. Η search.find δέχεται το πλήρες αντικείμενο φίλτρων, πεδίο προς πεδίο. Η search.query δέχεται ένα ερώτημα γραμμένο στη γλώσσα της γραμμής εντολών της εφαρμογής (s cube p>30 E>50, που περιγράφεται στο Λίστα εντολών) και ρέει τις ίδιες θέσεις· είναι ο μόνος τρόπος να προσεγγιστούν μέσω δικτύου τα φίλτρα που δεν έχουν προφανές πεδίο — μοτίβο κίνησης, κείμενο σχολίου, παίκτης, ημερομηνία, εξαιρούμενες ζαριές, ζώνες και blots. Η search.parse δεν ψάχνει τίποτα: απαντά τι σημαίνει ένα ερώτημα — τα φίλτρα που δηλώνει, την κανονική του μορφή (δύο ισοδύναμα ερωτήματα τη μοιράζονται, πράγμα που καθιστά μια αποθηκευμένη αναζήτηση συγκρίσιμη) και τα διαγνωστικά του.

Ένα ερώτημα που φέρει σύμβολο το οποίο δεν αναγνωρίζεται απορρίπτεται (400 invalid, με αναφορά του συμβόλου) αντί να εκτελεστεί περιορίζοντας σιωπηλά την αναζήτηση. Ένα σύμβολο κατανοητό αλλά χωρίς αποτέλεσμα εδώ — το x, που ενεργοποιεί τη δομή αποκλεισμού, η οποία είναι πίνακας και όχι κείμενο — ταξιδεύει στην κεφαλίδα X-BlunderDB-Query-Diagnostics, ώστε το σώμα να παραμένει NDJSON θέσεων για όλους τους υπάρχοντες πελάτες.

Δύο μέθοδοι της οικογένειας positions αποκωδικοποιούν μια θέση χωρίς να την αποθηκεύουν: η positions.fromXGID ανακατασκευάζει μια θέση από μια συμβολοσειρά XGID και η positions.fromXGP από ένα αρχείο μεμονωμένης θέσης .xgp.

POST /v1/exports.sqlite εξάγει ολόκληρο το τρέχον tenant — θέσεις, συλλογές, αγώνες, τουρνουά, αναλύσεις, σχόλια, κινήσεις που παίχτηκαν, βιβλιοθήκη φίλτρων και πακέτα Anki — σε ένα αρχείο SQLite που ανοίγει όπως είναι από τον σταθμό εργασίας. Το σώμα JSON του αιτήματος είναι προαιρετικό: τα watermarkOrigin / watermarkNote θέτουν σήμανση υπογεγραμμένη με την ταυτότητα του ίδιου του δαίμονα (--identity-dir) — χωρίς αυτά τα πεδία, η εξαγωγή δεν φέρει καμία σήμανση· η αίτησή τους χωρίς ρυθμισμένη ταυτότητα αποτυγχάνει με τον κωδικό invalid. Το collectionIds περιορίζει την εξαγωγή σε αυτές τις συλλογές και στις θέσεις τους, με αναλύσεις, σχόλια και κινήσεις που παίχτηκαν, χωρίς τη βιβλιοθήκη φίλτρων και τα πακέτα Anki.

Ο διαμοιρασμός μιας συλλογής μεταξύ tenants περνά από τον πελάτη, ποτέ από ανάγνωση ενός tenant μέσα στο άλλο: το tenant που δίνει καλεί το exports.sqlite με collectionIds (και μια σήμανση, ώστε ο παραλήπτης να ξέρει από πού προέρχεται το αρχείο), το tenant που παραλαμβάνει στέλνει το αρχείο στο imports.db. Κάθε αίτημα φέρει το δικό του X-Tenant-ID· ο διαμεσολαβητής (proxy) αποφασίζει ποιος δικαιούται να κάνει το ένα και το άλλο. Κατά την εισαγωγή, μια συλλογή ενώνεται με την ομώνυμη συλλογή του παραλήπτη ή δημιουργείται· οι θέσεις της προστίθενται στο τέλος, χωρίς διπλότυπα. Μια ζωντανή συλλογή του παραλήπτη δεν λαμβάνει καμία θέση: το περιεχόμενό της ορίζεται από το ερώτημά της. Η εισαγωγή μιας βάσης στην εφαρμογή επιφάνειας εργασίας ακολουθεί τον ίδιο κανόνα.

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

Η οικογένεια training κρατά το ημερολόγιο της καρτέλας Εκπαίδευση: το training.save προσθέτει μια συνεδρία (exercise, seedSource, μετρήσεις, items) και επιστρέφει το id της (γίνεται δεκτό το Idempotency-Key)· το training.sessions ξαναδιαβάζει τις συνεδρίες, την πιο πρόσφατη πρώτα (προαιρετικά exercise και limit)· το training.numberStats συγκεντρώνει τα items μιας άσκησης ανά τύπο αριθμού. Οι ίδιες οι ερωτήσεις επιλέγονται από τον πελάτη.

Το gammonnet.evaluate αξιολογεί μια γυμνή θέση (position ή xgid), χωρίς να διαβάζει ή να γράφει τίποτα στο tenant: με ζάρια, τις καλύτερες κινήσεις (candidates, 5 από προεπιλογή, το πολύ 20)· χωρίς ζάρια, την απόφαση κύβου. Το ply κυμαίνεται από 0 έως 2 (2 από προεπιλογή)· μια βαθύτερη αναζήτηση είναι δουλειά του analyzeMissing.

Η οικογένεια anki αποκτά έξι μεθόδους που επεκτείνουν τον προγραμματιστή διαστηματικής επανάληψης (FSRS): anki.reviewLog (καταγραφή κάθε επανάληψης — βαθμολογία και αποτέλεσμα FSRS — για στατιστικά διατήρησης και ένα πιστό ιστορικό), anki.forecast (προβολή του αριθμού καρτών που οφείλονται τις επόμενες ημέρες, συμπεριλαμβανομένων των καθυστερημένων καρτών), anki.suspendCard / anki.buryCard / anki.removeCard (αφαίρεση μιας κάρτας από την ουρά επανάληψης προσωρινά ή οριστικά) και anki.retention (το ποσοστό επιτυχίας που μετριέται στις επαναλήψεις ενός deck, σε σχέση με τον στόχο που έχει ορίσει ο ιδιοκτήτης του).

Σημείωση

Το anki.retention αντικαθιστά το anki.optimizeParams, το οποίο προσάρμοζε τον στόχο προς το παρατηρούμενο ποσοστό και μπορούσε να το γράψει. Ο στόχος διατήρησης είναι μια επιλογή πάνω στον συμβιβασμό φόρτου/ποιότητας, το μετρούμενο ποσοστό είναι το αποτέλεσμά του, και η σύζευξη του ενός με το άλλο είναι ακριβώς ο μηχανισμός που απορρίπτουν οι συγγραφείς του FSRS. Η μέθοδος μόνο μετρά, χωρίς ποτέ να γράφει.

Η οικογένεια stats παρέχει το stats.playerTable, που επιστρέφει μία γραμμή στατιστικών ανά παίκτη (αγώνες, νίκες/ήττες, μετρημένες αποφάσεις, PR συνολικό / πούλια / κύβος, Snowie Error Rate, λάθη, σοβαρά λάθη και τύχη) για τους αγώνες που κρατά το φίλτρο. Όπως και στο γραφικό περιβάλλον, ο πίνακας τιμά από το φίλτρο μόνο την περίοδο, τα τουρνουά και το μήκος των αγώνων: η επιλογή παίκτη και ο τύπος απόφασης αγνοούνται, αφού ο πίνακας αφορά όλους τους παίκτες και ήδη διαχωρίζει πούλια και κύβο σε ξεχωριστές στήλες. Το πεδίο luck_known δηλώνει αν μετρήθηκε η τύχη για τον παίκτη· το luck_rate_mp δεν πρέπει να διαβάζεται όταν είναι false, καθώς άγνωστη τύχη δεν σημαίνει μηδενική τύχη.

Το φίλτρο που περνάει στις μεθόδους stats δέχεται, δίπλα στο PlayerName, και ένα πεδίο PlayerAliases: τις άλλες γραφές με τις οποίες υπέγραψε το ίδιο πρόσωπο. Καθώς το όνομα κάθε παίκτη πληκτρολογείται με το χέρι σε κάθε αρχείο, το ίδιο πρόσωπο εμφανίζεται συχνά με πολλές γραφές, και ένα φίλτρο που κρατά μόνο μία υπολογίζει σε μέρος των αγώνων χωρίς τίποτα να φαίνεται ανώμαλο. Το πεδίο είναι καθαρά προσθετικό: κρατούνται οι αποφάσεις οποιουδήποτε από τα ονόματα. Η συγχώνευση των ονομάτων στη βάση (MergePlayers) είναι η άλλη απάντηση και προορίζεται για βάσεις που δεν λάβατε από κάποιον άλλον — ξαναγράφει τους αγώνες όλων.

Δύο μέθοδοι συμπληρώνουν την ισοτιμία με το γραφικό περιβάλλον: το stats.tournamentBadges επιστρέφει, για κάθε τουρνουά της βάσης, τον δείκτη που εμφανίζεται στην κάρτα του (PR του παίκτη αναφοράς), και το matches.findByHash δηλώνει αν ένας αγώνας υπάρχει ήδη, με βάση τα δύο αποτυπώματα ανίχνευσης διπλότυπων — αρκετό για να αποφευχθεί μια περιττή εισαγωγή πριν ξεκινήσει.

Το πεδίο winner ενός παιχνιδιού, που λαμβάνεται από το matches.createGame και επιστρέφεται από το matches.games, έχει μία μόνο κωδικοποίηση: 1 για τον παίκτη 1, -1 για τον παίκτη 2, 0 για ένα ημιτελές παιχνίδι. Ένας πελάτης που εξακολουθεί να στέλνει 0, 1 ή -1 με την έννοια του gnubg (0 για τον παίκτη 1, 1 για τον παίκτη 2) καταχωρίζει τον αντίθετο νικητή.

Το analyses.repair επανυπολογίζει τις αποκανονικοποιημένες στήλες μιας ανάλυσης (μεταξύ αυτών το cube_error) από την πλήρη ανάλυσή της και επιστρέφει το πλήθος των γραμμών που πράγματι διορθώθηκαν. Οι στήλες αυτές είναι απλώς μια προβολή: ένα σφάλμα προβολής επιδιορθώνεται λοιπόν χωρίς νέα εισαγωγή των αρχείων προέλευσης. Η λειτουργία είναι ρητή και δεν ενεργοποιείται ποτέ μόνη της — ούτε στο άνοιγμα μιας βάσης ούτε από κάποια μετανάστευση, αφού δεν ευθύνεται το σχήμα. Μια μη αναγνώσιμη ανάλυση αφήνεται ως έχει αντί να μηδενιστεί. Η γνωστή περίπτωση: τα μη-διπλασιάσματα που το gnuBG χαρακτηρίζει «Double No», τα οποία διαβάζονταν εσφαλμένα πριν από την έκδοση 0.33.0 και έφεραν το σφάλμα ενός διπλασιασμού που δεν έγινε ποτέ.

Το gammonnet.analyzeMissing ενεργοποιεί τη συμπλήρωση gammonNet του τρέχοντος tenant: γράφει μια ανάλυση για κάθε θέση που δεν έχει καμία (ADR-0013, ADR-0015). Είναι μια λειτουργία βιβλιοθήκης — διαβάζει και γράφει αποθηκευμένες θέσεις και αναλύσεις — ποτέ ένας γυμνός αξιολογητής: το blunderdb serve λειτουργεί πάνω σε μια βιβλιοθήκη, το gammonnet serve αξιολογεί μια θέση. Η απάντηση είναι μια ροή NDJSON (started, progress, έπειτα done ή error/cancelled), στο ίδιο πρότυπο με τα σημεία πρόσβασης εισαγωγής· το gammonnet.analyzeMissing.cancel (με το job_id που ελήφθη στο συμβάν started) ακυρώνει μια συμπλήρωση σε εξέλιξη και εξυπηρετεί αδιακρίτως είτε μια συμπλήρωση είτε μια επανάλυση (παρακάτω). Είναι η ίδια λειτουργία με την αυτόματη ενεργοποίηση μετά την εισαγωγή και τον ρητό χειρισμό της γραφικής διεπαφής, καθώς και με την υποεντολή blunderdb analyze (βλ. Διεπαφή γραμμής εντολών (CLI)) — τρεις μορφές, μία μόνο λογική.

Το gammonnet.sweepStale είναι το αντίστοιχο του analyzeMissing για την επανάλυση αντί της συμπλήρωσης: κάθε θέση της οποίας η ανάλυση προέρχεται εξ ολοκλήρου από το gammonNet αλλά είναι παρωχημένη — παλαιότερη έκδοση μηχανισμού από την τρέχουσα, ή βάθος διαφορετικό από το ply — επαναξιολογείται στο ζητούμενο βάθος. Το κριτήριο παρωχήματος είναι κοινό με την ίδια δέσμη της γραφικής διεπαφής και με το blunderdb analyze --stale (καμία διπλή λογική στους τρεις τρόπους λειτουργίας)· μια θέση που φέρει ανάλυση XG, GNUbg ή BGBlitz δεν επηρεάζεται ποτέ, ανεξαρτήτως του περιεχομένου gammonNet της — η προστασία του ADR-0013 παραμένει άνευ όρων. Ίδια μορφή NDJSON με το analyzeMissing, και το τελικό συμβάν καθεμιάς από τις δύο διαδρομές φέρει την κατανομή evaluated/refused/failed: μια θέση που το gammonNet αρνείται να αξιολογήσει (ένα σκορ αγώνα εκτός του εύρους του πίνακά του, μια απόφαση διπλασιασμού που το μοντέλο αρνείται) μετράει ως refused, όχι failed — δεν ξαναδοκιμάζεται μάταια στην επόμενη διέλευση, σε αντίθεση με μια θέση που πράγματι απέτυχε.

Το rollout.position παίζει μια θέση της βιβλιοθήκης (positionId) με ένα rollout και επιστρέφει, για κάθε υποψήφιο, την ισοτιμία (equity), το διάστημά της 95 % και το JSD· το rollout φέρει τις ρυθμίσεις (fast, standard ή standard,ply=1…), το store καταγράφει το ολοκληρωμένο rollout ως δεύτερη ανάλυση, δίπλα σε αυτήν που φέρει η θέση, την οποία δεν αντικαθιστά ποτέ. Μια γυμνή θέση (ένα XGID) απορρίπτεται: ο δαίμονας λειτουργεί σε μια βιβλιοθήκη. Το rollout.filter είναι η μορφή παρτίδας του blunderdb analyze --rollout: οι θέσεις που επιλέγει το query (η γλώσσα της αναζήτησης) και που δεν φέρουν ακόμη rollout με τις ίδιες ρυθμίσεις παίζονται η μία μετά την άλλη και καταγράφονται καθώς προχωρούν, σε ροή NDJSON (started, progress μετά από κάθε σειρά παιχνιδιών, έπειτα done, cancelled ή quota_exceeded)· το rollout.filter.cancel το ακυρώνει με το job_id του. Ένα tenant εκτελεί μία μόνο παρτίδα τη φορά, rollout ή gammonNet. Το rollout.list διαβάζει τα καταγεγραμμένα rollout μιας θέσης.

Συσχέτιση και επιχειρησιακές μετρικές

Κάθε αίτημα λαμβάνει ένα αναγνωριστικό συσχέτισης: αυτό που στέλνει ο πελάτης (ή ένας reverse proxy) στην κεφαλίδα X-Request-Id, αλλιώς ένα παραγόμενο — και στις δύο περιπτώσεις επιστρέφεται στην ίδια κεφαλίδα της απόκρισης και προστίθεται στη γραμμή καταγραφής που κλείνει το αίτημα (πεδίο request_id). Ένα traceparent (W3C Trace Context), αν υπάρχει, μεταβιβάζεται αυτούσιο στην ίδια γραμμή καταγραφής — ο δαίμονας ούτε το αναλύει ούτε το επικυρώνει και δεν ενσωματώνει καμία βιβλιοθήκη ιχνηλάτησης: είναι μια γέφυρα για τη συσχέτιση αυτών των καταγραφών με έναν μηχανισμό ιχνηλάτησης που τρέχει ανάντη, τίποτε περισσότερο.

Πέρα από τον όγκο των αιτημάτων και τον χρόνο απόκρισης, το /metrics δημοσιεύει δείκτες για την εργασία εν εξελίξει, αόρατη διαφορετικά σε μια κολλημένη εισαγωγή ή παρτίδα gammonNet (ένα μοναδικό πολύ μακρύ αίτημα, όχι πολλά αιτήματα):

  • blunderdb_imports_inflight — εισαγωγές σε εξέλιξη, για όλους τους tenants·

  • blunderdb_import_spool_bytes — bytes δεσμευμένα αυτή τη στιγμή στο όριο spool εισαγωγής (βλ. --rate-limit-* παραπάνω για το αντίστοιχο σε αιτήματα ανά δευτερόλεπτο)·

  • blunderdb_gammonnet_sweep_inflight — σαρώσεις ανάκτησης gammonNet σε εξέλιξη, για όλους τους tenants·

  • blunderdb_database_size_bytes — μέγεθος του κύριου αρχείου SQLite, ή pg_database_size υπό PostgreSQL (ολόκληρη η βάση, όχι ανά tenant, όπως και οι δείκτες δεξαμενής συνδέσεων παρακάτω)· απουσιάζει όσο δεν έχει δημοσιευθεί ακόμη καμία μέτρηση.

Ένα προφίλ μνήμης ή CPU της διεργασίας είναι διαθέσιμο ξεκινώντας με --pprof-addr <κόμβος:θύρα> (net/http/pprof): απενεργοποιημένο από προεπιλογή και σκόπιμα σε διεύθυνση ξεχωριστή από την --addr, αφού τα σημεία αυτά δεν έχουν καμία έννοια tenant.

Συμπίεση των ροών

Οι λίστες NDJSON επαναλαμβάνουν τα ίδια ονόματα πεδίων σε κάθε γραμμή. Ο δαίμονας τις συμπιέζει όταν ο πελάτης το δέχεται: στείλτε Accept-Encoding: gzip και η απόκριση επιστρέφει ως Content-Encoding: gzip. Μετρημένο σε λίστα αγώνων: 13,5 % του αρχικού μεγέθους στις χίλιες γραμμές, 14,6 % στις εκατό.

Η συμπίεση δεν αλλάζει σε τίποτα τον σταδιακό χαρακτήρα της ροής — κάθε εγγραφή στέλνεται στον πελάτη όπως πριν, απλώς συμπιεσμένη στον δρόμο. Ισχύει μόνο για απαντήσεις NDJSON, JSON και κειμένου: μια εξαγωγή βάσης ή ένα δοχείο .dbx είναι ήδη συμπιεσμένο, και το εκ νέου gzip θα το μεγάλωνε μόνο. Το Accept-Encoding: gzip;q=0 την αρνείται ρητά.

Ένας μόνο tenant σε SQLite

Το backend SQLite δεν έχει στήλη tenant: όλα τα δεδομένα βρίσκονται στους ίδιους πίνακες, χωρίς διαχωρισμό. Σε αυτό το backend ο δαίμονας αρνείται επομένως κάθε X-Tenant-ID εκτός του 1 — δεχόμενος τα υπόλοιπα θα σέρβιρε στον καθένα τις γραμμές όλων, πίσω από μια κεφαλίδα που ισχυρίζεται το αντίθετο. Μια εγκατάσταση με πραγματικά πολλούς tenants χρειάζεται το backend PostgreSQL.

Ανάγνωση πολλών tenants

Ένας προπονητής που διαβάζει τους αγώνες των μαθητών του, μια λέσχη που μοιράζεται μια βιβλιοθήκη: η σχέση ανάμεσα σε αυτούς τους λογαριασμούς ανήκει στον host που τους πιστοποιεί, ποτέ στον δαίμονα. Ο proxy την εκφράζει με την κεφαλίδα X-Read-Tenants, μια λίστα tenants χωρισμένων με κόμματα (X-Read-Tenants: 2, 3), την οποία θέτει δίπλα στο X-Tenant-ID. Ο δαίμονας την εμπιστεύεται όπως το X-Tenant-ID και δεν εξουσιοδοτεί ο ίδιος τίποτα (ADR-0063).

Η λειτουργία είναι απενεργοποιημένη από προεπιλογή, και απενεργοποιημένη σημαίνει απορριπτέα: όσο ο δαίμονας δεν ξεκινά με --read-tenants (ή BLUNDERDB_READ_TENANTS=true· Config.TrustReadTenants για έναν host που ενσωματώνει τη μηχανή), κάθε αίτημα που φέρει μη κενό X-Read-Tenants απορρίπτεται (400), όποια κι αν είναι η διαδρομή. Ενεργοποιήστε την μόνο αφού ο proxy έχει ρυθμιστεί ώστε να αφαιρεί κάθε τιμή που στέλνει ο πελάτης και να θέτει ο ίδιος τη λίστα.

Μόνο οι αναγνώσεις /v1/across.* λαμβάνουν υπόψη αυτή την κεφαλίδα. Η πλήρης λίστα: across.searchFind, across.matchesList, across.statsCompute και across.playerTable· διαβάζουν πρώτα το X-Tenant-ID, έπειτα κάθε tenant της λίστας με τη σειρά της κεφαλίδας, το πολύ 64 διακριτούς tenants συνολικά. Σε έναν tenant της λίστας, που ονομάζεται με το id: across.matchesGet, across.matchMovePositions (οι θέσεις ενός αγώνα, κίνηση προς κίνηση) και across.analysesLoadByIds· ένας tenant που λείπει από τη λίστα απορρίπτεται εκεί. Κάθε αποτέλεσμα φέρει τον tenant προέλευσής του ("tenant": "2"), επειδή ένα id είναι μοναδικό μόνο μέσα στον tenant του· μια θέση φέρει επίσης το hash Zobrist της ("zobrist"), που υποδεικνύει το ίδιο ταμπλό σε όλους τους tenants. Το limit ισχύει για κάθε tenant· το 0 σημαίνει 1000, και μεγαλύτερη τιμή απορρίπτεται. Σε μια ροή NDJSON, ένα σφάλμα σε έναν όψιμο tenant έρχεται ως τελευταία γραμμή, μετά τα αποτελέσματα των tenants που έχουν ήδη διαβαστεί: ολόκληρη η ροή αποτυγχάνει τότε.

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

Κάθε εγγραφή παραμένει στο X-Tenant-ID: καμία άλλη διαδρομή δεν διαβάζει το X-Read-Tenants. Χωρίς την κεφαλίδα, μια ανάγνωση across.* αφορά μόνο το X-Tenant-ID. Μια κακοσχηματισμένη κεφαλίδα (ένα όνομα, ένα κενό στοιχείο, πάνω από 64 tenants) ή μια κεφαλίδα που στέλνεται σε πολλές γραμμές απορρίπτει ολόκληρο το αίτημα, όποια κι αν είναι η διαδρομή. Στην SQLite, που έχει μόνο έναν tenant, η λίστα μπορεί να περιέχει μόνο 1: η κεφαλίδα δεν διευρύνει τίποτα εκεί. Αυτές οι διαδρομές ανήκουν στον διακομιστή: η εφαρμογή γραφείου και το call έχουν έναν μόνο tenant.

Ένα αίτημα across.* κοστίζει έως και 64 αναγνώσεις στην αποθήκευση, αλλά το όριο ρυθμού (--rate-limit-rps) το μετρά μία μόνο φορά, για το X-Tenant-ID: υπολογίστε αναλόγως τη βάση και αυτό το όριο, ή αφήστε τον proxy να περιορίσει τη λίστα. Το αρχείο καταγραφής πρόσβασης μιας διαδρομής across.* φέρει τη λίστα που ελήφθη (πεδίο read_tenants). Η κεφαλίδα δεν συγκαταλέγεται στις επιτρεπόμενες κεφαλίδες CORS: μόνο ο proxy την γράφει, ποτέ ένας περιηγητής.

Αντίγραφα ασφαλείας και επαναφορά

Τέσσερις κινήσεις, ανάλογα με το τι θέλουμε να ανακτήσουμε.

Τα πάντα, υπό PostgreSQL — το pg_dump είναι το εργαλείο, και το blunderDB δεν έχει τίποτα να προσθέσει:

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

Τα πάντα, με SQLite σε container — το αρχείο ανοίγει σε λειτουργία WAL (ο δαίμονας κωδικοποιεί το journal_mode(WAL) στη συμβολοσειρά σύνδεσής του, για όλες τις συνδέσεις του pool): δίπλα στο blunderdb.db ζουν ένα -wal και ένα -shm, και οι πιο πρόσφατες εγγραφές βρίσκονται στο -wal. Η αντιγραφή μόνο του .db ενός δαίμονα που τρέχει δίνει επομένως ένα ελλιπές αρχείο, χωρίς τίποτα να το επισημαίνει. Δύο ασφαλείς τρόποι:

  • σταματήστε τον δαίμονα, έπειτα αντιγράψτε ολόκληρο τον τόμο — στη διακοπή τα τρία αρχεία είναι συνεπή, και είναι ο τόμος, όχι το .db μόνο του, η μονάδα που φυλάσσεται·

  • μην αντιγράψετε καθόλου το αρχείο: το /v1/exports.sqlite (παρακάτω) γράφει ένα πλήρες .db ενώ ο δαίμονας τρέχει, και είναι η μόνη κίνηση που δεν απαιτεί καμία διακοπή.

Το /ops/maintenance.vacuum όντως ενσωματώνει το WAL στο κύριο αρχείο πριν το ξαναγράψει, αλλά δεν παγώνει τη βάση: η εγγραφή που ακολουθεί ξαναρχίζει στο WAL. Είναι εντολή συμπύκνωσης, όχι μέθοδος αντιγράφου ασφαλείας.

Ένας tenant μόνος — το /v1/exports.sqlite γράφει τη βάση ενός tenant σε ένα συνηθισμένο αρχείο .db, αυτό που ανοίγει η εφαρμογή γραφείου:

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

Αυτή η εντολή εκτελείται στο μηχάνημα του δαίμονα: στοχεύει τον τοπικό ακροατή, παρακάμπτει τον proxy, και θέτει επομένως η ίδια την κεφαλίδα του tenant. Από έξω, ρωτάμε τον proxy, και ο tenant είναι αυτός του πιστοποιημένου λογαριασμού — η κεφαλίδα δεν δίνεται, ο proxy σβήνει εκείνη του πελάτη πριν εισαγάγει τη δική του:

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

Επαναφορά αυτού του αρχείου — το migrate το αντιγράφει στον tenant που ορίζετε:

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

Το migrate αρνείται να γράψει σε tenant που περιέχει ήδη κάτι, και λέει τι («128 θέσεις, 3 αγώνες»)· το --on-conflict skip προχωρά παρ” όλα αυτά και αφήνει την αποδιπλοποίηση Zobrist να συγχωνεύσει τις θέσεις.

Τι δεν αντιγράφει το migrate, και το ανακοινώνει στο τέλος με τον ακριβή αριθμό: τις τράπουλες Anki και τις κάρτες τους, τη βιβλιοθήκη φίλτρων, τα ιστορικά αναζήτησης και εντολών, και την κατάσταση συνεδρίας. Πρόκειται για δεδομένα χρήσης της εφαρμογής γραφείου· οι θέσεις στις οποίες παραπέμπουν έχουν όντως μεταφερθεί.

Τα κατώφλια σφάλματος και blunder, αντιθέτως, αντιγράφονται: δεν είναι δεδομένα χρήσης αλλά η συνήθεια ανάγνωσης από την οποία εξαρτώνται οι μετρήσεις, και ένας tenant που θα μετρούσε διαφορετικά από το αρχείο από το οποίο προέρχεται θα έκανε τη μετάπτωση μια σιωπηλή αλλαγή νοήματος.

Ο tenant ρυθμίζει τα δικά του μέσω POST /v1/librarySettings.load και /v1/librarySettings.save. Σε αντίθεση με τα metadata, που είναι καθολική υποδομή εκτεθειμένη μόνο για ανάγνωση, ο πίνακας των ρυθμίσεων φέρει ένα tenant_id και ζει υπό Row-Level Security: ένας tenant που γράφει τα κατώφλιά του δεν φτάνει παρά μόνο τις δικές του γραμμές.

Ο σταθμός εργασίας και ο διακομιστής

Η εφαρμογή γραφείου ανοίγει αρχεία .db, όχι URL: δεν συνδέεται σε κανέναν δαίμονα serve, και δεν υπάρχει πουθενά πεδίο για να πληκτρολογήσετε μια διεύθυνση. Ο διακομιστής και ο σταθμός εργασίας ανταλλάσσουν αρχεία, με δύο συμμετρικές κινήσεις:

Δεν υπάρχει καμία ανάγνωση μεταξύ tenants. Ο διαχωρισμός είναι πλήρης: τίποτα από όσα αποθηκεύει ένα tenant δεν είναι ορατό σε άλλο, από καμία διαδρομή, και καμία κλήση δεν δέχεται tenant ως παράμετρο — κάθε αίτημα γνωρίζει μόνο εκείνο που του έθεσε ο proxy. Ένας προπονητής που θέλει να δει τους αγώνες των μαθητών του έχει επομένως δύο δρόμους, και οι δύο ρητοί:

  • να του ανοίξει στον proxy έναν επιπλέον λογαριασμό, συνδεδεμένο με το tenant του μαθητή: είναι ο πίνακας αντιστοίχισης του proxy, ποτέ ο δαίμονας, που αποφασίζει ποιο tenant βλέπει μια συνεδρία·

  • να του ζητήσει μια εξαγωγή — το .db που παράγει το exports.sqlite ή το παράθυρο εξαγωγής της εφαρμογής γραφείου — και να το ανοίξει στον δικό του σταθμό.

Ανάπτυξη με Docker

Το αποθετήριο παρέχει ένα Dockerfile.serve που κατασκευάζει μια ελάχιστη εικόνα κοντέινερ του δαίμονα: μεταγλωττίζεται μόνο το εκτελέσιμο serve (καθαρή Go, χωρίς γραφική διεπαφή και χωρίς CGO, άρα στατικά συνδεδεμένο), και στη συνέχεια τοποθετείται σε μια εικόνα distroless.

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

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

Η κατασκευή ξεκινά από τη ρίζα του αποθετηρίου, και το προεπιλεγμένο backend της εικόνας είναι το postgres.

Η εικόνα ακούει στη θύρα 8080 και διαμορφώνεται μέσω μεταβλητών περιβάλλοντος (BLUNDERDB_BACKEND, BLUNDERDB_DSN, BLUNDERDB_ADDR, BLUNDERDB_RLS). Δηλώνει ένα HEALTHCHECK που εκτελεί κάθε 30 δευτερόλεπτα blunderdb healthcheck (ένα αίτημα στο /readyz — η εικόνα distroless δεν έχει ούτε curl ούτε κέλυφος): το docker ps δείχνει την κατάσταση healthy ή unhealthy του container, και το Compose ή ένας ενορχηστρωτής μπορούν να περιμένουν να γίνει διαθέσιμος ο δαίμονας πριν ξεκινήσουν ό,τι εξαρτάται από αυτόν.

Δημοσιευμένη εικόνα

Δεν χρειάζεται να κατασκευάσετε την εικόνα μόνοι σας: κάθε δημοσιευμένη έκδοση του blunderDB προωθεί τη δική της στο μητρώο του GitHub (GHCR), με το όνομα ghcr.io/kevung/blunderdb-serve. Διατίθενται δύο ετικέτες: ο αριθμός της έκδοσης, παγιωμένος για πάντα σε αυτή την εικόνα, και latest, που ακολουθεί την τελευταία δημοσιευμένη έκδοση. Όλη η τεκμηρίωση τις σημειώνει ghcr.io/kevung/blunderdb-serve:<version>: τη θέση του <version> παίρνει ο αριθμός μιας δημοσιευμένης έκδοσης, και αυτή η μορφή, ποτέ το latest, είναι εκείνη που καρφιτσώνει μια ανάπτυξη παραγωγής. Η εικόνα παρέχεται για linux/amd64 και linux/arm64· το Docker επιλέγει την αρχιτεκτονική του υπολογιστή-υποδοχής.

# 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 είναι το σημείο προσάρτησης που ετοιμάζει η εικόνα, με τα δικαιώματα του μη προνομιούχου χρήστη της, και το XDG_DATA_HOME της: ο τόμος που προσαρτάται εκεί δεν χρησιμεύει μόνο για τη βάση, οι πίνακες bearoff υπολογίζονται εκεί μία φορά, στο /data/blunderdb, και ξαναβρίσκονται στις επόμενες εκκινήσεις. Χωρίς τόμο, υπολογίζονται ξανά σε κάθε εκκίνηση του container — μερικά δευτερόλεπτα — και ο δαίμονας το λέει κατά την εκκίνηση αν δεν μπορεί να τους γράψει (could not prepare the bearoff tables; the exact regime will be unavailable), οπότε εξυπηρετεί κανονικά, με μόνο το εκτιμώμενο καθεστώς στις θέσεις bearoff.

Η εικόνα φέρει τις συνήθεις ετικέτες OCI (org.opencontainers.image.source, .version, .revision, .licenses): η docker inspect δείχνει από ποιο commit και ποια έκδοση προέρχεται. Κατασκευάζεται από τη συνεχή ενσωμάτωση με βάση το Dockerfile.serve του αποθετηρίου, ακριβώς όπως παραπάνω· η τοπική κατασκευή ή η λήψη της δημοσιευμένης εικόνας δίνουν το ίδιο εκτελέσιμο.

Προειδοποίηση

Όπως και ο ίδιος ο δαίμονας, το κοντέινερ δεν εκτελεί καμία πιστοποίηση ταυτότητας (ADR-0005): εμπιστεύεται την κεφαλίδα X-Tenant-ID όπως τη λαμβάνει. Πρέπει να τοποθετείται πίσω από έναν reverse-proxy υπεύθυνο για την πιστοποίηση ταυτότητας, ο οποίος ορίζει ο ίδιος αυτήν την κεφαλίδα, και να μην εκτίθεται ποτέ απευθείας στο δημόσιο Διαδίκτυο. Τα παραδείγματα παραπάνω δημοσιεύουν τη θύρα μόνο στο 127.0.0.1 ακριβώς για αυτόν τον λόγο, και το --addr δεσμεύεται επίσης στο 127.0.0.1: ο proxy βρίσκεται στο ίδιο μηχάνημα.

Ανάπτυξη πίσω από έναν proxy πιστοποίησης ταυτότητας

Το ADR-0005 κάνει τον reverse-proxy ολόκληρο το όριο ασφαλείας του δαίμονα: μόνο αυτός πιστοποιεί τον καλούντα, μόνο αυτός έχει δικαίωμα να θέτει την κεφαλίδα X-Tenant-ID, και πρέπει να αφαιρεί συστηματικά κάθε τιμή που στέλνει ο πελάτης πριν εισαγάγει τον πιστοποιημένο tenant — διαφορετικά οποιοσδήποτε μπορεί να υποδυθεί οποιοδήποτε tenant απλώς ονομάζοντάς το. Το μοντέλο απειλής χωράει σε μία φράση: ο δαίμονας προϋποθέτει ένα έμπιστο εσωτερικό δίκτυο, και όποιος τον προσεγγίζει απευθείας είναι, για αυτόν, ο tenant που ισχυρίζεται πως είναι. Το αποθετήριο παρέχει ένα πλήρες παράδειγμα, έτοιμο για εκτέλεση, στον κατάλογο deploy/. Ζει στο αποθετήριο git, όχι στην εικόνα container: πρέπει επομένως να κλωνοποιήσετε το αποθετήριο, ή να κατεβάσετε τα δύο αρχεία που παρατίθενται παρακάτω καθώς και το deploy/.env.example στον ίδιο κατάλογο.

Το αρχείο Compose τοποθετεί τον Caddy — πιστοποίηση HTTP Basic για επίδειξη — μπροστά από το blunderdb-serve και την PostgreSQL, με ενεργοποιημένο το Row-Level Security. Μόνο ο Caddy δημοσιεύει θύρα: οι άλλες δύο υπηρεσίες ζουν σε ένα δίκτυο Docker δηλωμένο internal: true, που δεν έχει διαδρομή ούτε προς τον host ούτε προς το Internet, όποια ports: κι αν τους πρόσθετε μια μεταγενέστερη τροποποίηση.

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

Το Caddyfile πιστοποιεί, αντιστοιχίζει τον πιστοποιημένο λογαριασμό στον ακέραιο του tenant (map), και έπειτα τον εισάγει στο X-Tenant-ID αφού έχει σβήσει ρητά κάθε τιμή που έλαβε από τον πελάτη: ο φρουρός header_up X-Tenant-ID "" προηγείται της εισαγωγής, ώστε μια κεφαλίδα σταλμένη από τον πελάτη να μην μπορεί να φτάσει στον δαίμονα, όποιες κι αν είναι οι μεταγενέστερες τροποποιήσεις του αρχείου.

Το ίδιο ισχύει για το X-Read-Tenants (Ανάγνωση πολλών tenants): ο proxy αφαιρεί αυτό του πελάτη και το θέτει μόνο αν γνωρίζει τη σχέση ανάμεσα στους λογαριασμούς· τα παραδείγματα του αποθετηρίου δεν γνωρίζουν καμία και πάντα το αφαιρούν.

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

Δύο ακόμη αρχεία συμπληρώνουν τον κατάλογο: το deploy/nginx-tenant-proxy.conf επαναλαμβάνει το ίδιο σχήμα σε απόσπασμα nginx (proxy_set_header X-Tenant-ID "" και έπειτα proxy_set_header X-Tenant-ID $tenant_id, με το μπλοκ map $remote_user $tenant_id), για όποιον έχει ήδη έναν nginx σε λειτουργία· το deploy/README.md διατυπώνει το μοντέλο απειλής και ό,τι δεν πρέπει ποτέ να γίνει.

Η πιστοποίηση HTTP Basic του Caddyfile είναι μια επίδειξη, όχι μια σύσταση παραγωγής: αντικαθίσταται με forward_auth προς έναν πραγματικό πάροχο ταυτότητας (OIDC, εταιρικό SSO…), ο οποίος πιστοποιεί και έπειτα μεταβιβάζει την ταυτότητα στο ίδιο σημείο του αρχείου. Τα δύο συνθηματικά και οι δύο λογαριασμοί του πίνακα αντιστοίχισης αντικαθίστανται ομοίως.

Το deploy/Caddyfile.oidc είναι η συνταγή OpenID Connect: ο Caddy ρωτά το oauth2-proxy (forward_auth στο /oauth2/auth), το οποίο απαντά 202 με τη διεύθυνση του συνδεδεμένου λογαριασμού στο X-Auth-Request-Email, ή ανακατευθύνει στη σελίδα σύνδεσης του παρόχου. Το μπλοκ map αντιστοιχίζει αυτή τη διεύθυνση στον ακέραιο του tenant, και η ίδια προστασία header_up X-Tenant-ID "" προηγείται της έγχυσης. Η υπηρεσία oauth2-proxy που πρέπει να προστεθεί στο αρχείο Compose βρίσκεται στην αρχή του αρχείου.

Όρια ανά tenant

Μια κοινόχρηστη εγκατάσταση περιορίζει όσα παίρνει από αυτήν κάθε tenant με τα --quota-positions, --quota-analysis-seconds και --quota-imports (χωρίς επιλογή, τίποτα δεν περιορίζεται). Ο χρόνος υπολογισμού μετρά κάθε υπολογισμό της μηχανής που ζητά ο tenant: gammonnet.analyzeMissing, gammonnet.sweepStale, gammonnet.compare, gammonnet.cubeMatrix, gammonnet.evaluate, rollout.position και rollout.filter. Μετριέται σε δευτερόλεπτα CPU: ο χρόνος που πέρασε πολλαπλασιασμένος με τον αριθμό των αναζητήσεων που εκτελούνται ταυτόχρονα, έτσι ώστε ένας υπολογισμός κατανεμημένος σε όλους τους πυρήνες να κοστίζει όσο και η ίδια εργασία που γίνεται θέση προς θέση. Όταν εξαντληθεί ο χρόνος της ημέρας, οι διαδρομές αυτές απαντούν 429 με τον κωδικό quota_exceeded. Μια σάρωση ή ένα rollout.filter σε εξέλιξη κρατά όσα έχει καταγράψει και τελειώνει με το συμβάν quota_exceeded αντί για done· ένα rollout.position που διακόπτεται απαντά 429 και δεν καταγράφει τίποτα· μια σύγκριση που διακόπτεται επιστρέφει όσα έχει συγκεντρώσει με quotaExceeded: true και, στο gathered, τον αριθμό των θέσεων που έπρεπε να εξετάσει. Ο μετρητής μηδενίζεται τα μεσάνυχτα UTC και ζει στη μνήμη: η επανεκκίνηση του δαίμονα τον μηδενίζει. Το όριο θέσεων ελέγχεται στην αρχή μιας εισαγωγής, η οποία δεν διακόπτεται στη μέση: ένας tenant μπορεί να το ξεπεράσει όσο προσθέτουν οι εισαγωγές του που βρίσκονται σε εξέλιξη. Το positions.save και οι άλλες μεμονωμένες εγγραφές δεν το ελέγχουν. Κάθε απόρριψη φέρει στο details το όριο (quota, limit) και τη χρήση (used). Το tenants.quota επιστρέφει στον tenant που καλεί τα όριά του και τη χρήση του: αποθηκευμένες θέσεις, δευτερόλεπτα υπολογισμού της ημέρας, εισαγωγές σε εξέλιξη.

Τα όρια είναι λογιστική του δαίμονα, όχι σύνορο: εφαρμόζονται στον tenant που έχει ορίσει ο διαμεσολαβητής στο X-Tenant-ID.

Πλήρες σενάριο, από το μηδέν έως έναν δαίμονα που απαντά:

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

Το πρώτο αίτημα απορρίπτεται από τον Caddy, πριν καν φτάσει στον δαίμονα. Τα δύο επόμενα πιστοποιούνται ως «alice», που ο πίνακας αντιστοίχισης συνδέει με το tenant 1: επιστρέφουν το ίδιο σώμα ({"positions":0,"analyses":0,"matches":0,…}) και το ημερολόγιο του δαίμονα φέρει tenant=1 και για τα δύο — η τιμή 999 που έστειλε ο πελάτης δεν επέζησε από τον φρουρό του Caddyfile. Αυτό το σενάριο αναπαράχθηκε ως έχει.

Για να τραβήξετε τη δημοσιευμένη εικόνα αντί να την κατασκευάσετε, αντικαταστήστε στο docker-compose.yml τις τρεις γραμμές build: της υπηρεσίας blunderdb-serve με μία γραμμή image:, και έπειτα εκτελέστε docker compose up -d χωρίς --build:

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

Το αρχείο Compose δημοσιεύει τη θύρα του Caddy σε όλες τις διεπαφές (8080:80): αυτό περιμένει κανείς από έναν proxy, που υπάρχει ακριβώς για να τον φτάνουν. Αυτό που δεν πρέπει ποτέ να δημοσιεύεται είναι ο δαίμονας — και δεν δημοσιεύεται, δεν έχει κανένα ports:.

Ενημέρωση μιας ανάπτυξης

Το σχήμα μεταναστεύει αυτόματα κατά την εκκίνηση, και αυτή η μετανάστευση είναι μονής κατεύθυνσης: μια βάση που μετανάστευσε σε πρόσφατο σχήμα δεν είναι πια αναγνώσιμη από παλαιότερη έκδοση του blunderDB (βλ. Παράρτημα: Σχήμα της βάσης δεδομένων). Η σειρά των κινήσεων έχει επομένως σημασία.

  1. Πάρτε πρώτα αντίγραφο ασφαλείας, πριν από οτιδήποτε άλλο: είναι η μόνη οπισθοχώρηση (βλ. Αντίγραφα ασφαλείας και επαναφορά).

  2. Τραβήξτε την ετικέτα της έκδοσης που θέλετε, ποτέ το latest στην παραγωγή. Το latest ακολουθεί την τελευταία δημοσιευμένη έκδοση: η ανάπτυξη που το καρφιτσώνει αλλάζει έκδοση στο πέρασμα των επανεκκινήσεων, χωρίς κανείς να το έχει αποφασίσει και χωρίς το αντίγραφο ασφαλείας του βήματος 1 να είναι κατ” ανάγκη πρόσφατο.

  3. Επανεκκινήστε τον δαίμονα με τη νέα εικόνα. Μεταναστεύει το σχήμα πριν εξυπηρετήσει το παραμικρό αίτημα· αν η μετανάστευση αποτύχει, σταματά με το σφάλμα αντί να εξυπηρετήσει μια βάση μεταναστευμένη κατά το ήμισυ.

  4. Ελέγξτε τον ανιχνευτή διαθεσιμότητας. Το GET /readyz απαντά 200 και {"status":"ready","version":"…"} όταν η αποθήκευση απαντά και το σχήμα της είναι αυτό του δυαδικού· 503 και {"status":"down"} όταν η βάση είναι απρόσιτη· 503 και {"status":"version_mismatch","version":"…","expected":"…"} όταν τα δύο σχήματα διαφέρουν — η απάντηση ονομάζει αυτό της βάσης και αυτό που περιμένει το δυαδικό. Το blunderdb healthcheck δίνει την ίδια ετυμηγορία σε κωδικό επιστροφής.

Ένα version_mismatch που επιμένει μετά την επανεκκίνηση σημαίνει οπισθοδρόμηση: ένα παλαιότερο δυαδικό μπροστά σε μια ήδη μεταναστευμένη βάση. Δεν υπάρχει καθοδική μετανάστευση· είναι το αντίγραφο ασφαλείας του βήματος 1 που πρέπει να επαναφερθεί.

Σημαντικό

Πριν ενεργοποιήσετε το --read-tenants σε υπάρχουσα εγκατάσταση, ενημερώστε τον proxy: ένας proxy που ρυθμίστηκε πριν από αυτή την κεφαλίδα αφαιρεί μόνο το X-Tenant-ID και θα προωθούσε αυτούσιο ένα X-Read-Tenants που έστειλε ο πελάτης, ο οποίος θα διάβαζε τότε άλλους tenants. Χωρίς την επιλογή, ο δαίμονας απορρίπτει αυτή την κεφαλίδα: ένας proxy που την αφήνει να περάσει φαίνεται από τις απαντήσεις 400.

Backend PostgreSQL και πολλαπλοί χρήστες

Για μια κοινόχρηστη ανάπτυξη, το blunderDB μπορεί να αποθηκεύει τα δεδομένα στο PostgreSQL αντί σε ένα αρχείο SQLite. Το backend επιλέγεται μέσω του --backend postgres και της συμβολοσειράς σύνδεσης --dsn. Το σχήμα δημιουργείται και μεταναστεύεται αυτόματα κατά την εκκίνηση.

Τα δεδομένα είναι διαχωρισμένα ανά tenant (μισθωτή): κάθε αίτημα φέρει το αναγνωριστικό του tenant του (κεφαλίδα X-Tenant-ID, ένας θετικός δεκαδικός ακέραιος όπως 1 ή 42), κάτι που επιτρέπει σε πολλούς χρήστες να μοιράζονται την ίδια εγκατάσταση χωρίς να βλέπουν τα δεδομένα των άλλων. Ένα αναγνωριστικό που δεν είναι τέτοιος ακέραιος — ένα όνομα όπως alice ή default, 0, 007 — απορρίπτεται με 400 invalid: ο reverse-proxy είναι αυτός που αντιστοιχίζει έναν λογαριασμό στον ακέραιό του, ο δαίμονας δεν μαντεύει ποτέ.

Row-Level Security

Η επιλογή --rls ενεργοποιεί επιπλέον το Row-Level Security της PostgreSQL. Σε κάθε εκκίνηση, ο δαίμονας εγκαθιστά σε κάθε πίνακα που φέρει tenant_id μια πολιτική tenant_isolation που αφήνει να περάσουν μόνο οι γραμμές του tenant το οποίο ονομάζει η παράμετρος συνεδρίας current_setting('app.tenant_id'), και την επιβάλλει μέχρι και στον ιδιοκτήτη του πίνακα (FORCE ROW LEVEL SECURITY). Η παράμετρος αυτή τίθεται στη σύνδεση όταν βγαίνει από το pool και μηδενίζεται όταν επιστρέφει· μια σύνδεση χωρίς tenant δεν βλέπει καμία γραμμή και δεν εισάγει καμία. Πρόκειται για προαιρετική άμυνα σε βάθος, απενεργοποιημένη από προεπιλογή: το φιλτράρισμα ανά tenant του κώδικα της εφαρμογής παραμένει στη θέση του και στις δύο περιπτώσεις.

  • Ο ρόλος σύνδεσης πρέπει να είναι συνηθισμένος: ούτε υπερχρήστης, ούτε BYPASSRLS. Η PostgreSQL αφήνει αυτούς τους δύο να διαπερνούν όλες τις πολιτικές χωρίς λέξη, και η απομόνωση ξαναγίνεται αυτή του κώδικα της εφαρμογής και μόνο. Ο ίδιος ρόλος πρέπει αντιθέτως να κατέχει τους πίνακες, αφού αυτός εκτελεί τα ALTER TABLE και τα CREATE POLICY.

  • Σε μια ήδη γεμάτη βάση δεν υπάρχει τίποτα να μεταναστεύσει: η τοποθέτηση των πολιτικών είναι ιδεμποτικό DDL, που ξαναπαίζεται σε κάθε εκκίνηση μετά τη μετανάστευση του σχήματος. Κανένα δεδομένο δεν μετακινείται, καμία γραμμή δεν ξαναγράφεται· η ενεργοποίηση ή η αφαίρεση του --rls είναι απλώς μια επανεκκίνηση.

  • Το κόστος είναι μετρημένο: στην ανάγνωση μιας θέσης, 101,8 µs χωρίς, 177,0 µs με, δηλαδή +73,8 % — ίδιο container, ίδιες γραμμές, δύο pools που διαφέρουν μόνο ως προς αυτή τη σημαία. Πληρώνεται σε κάθε δανεισμό σύνδεσης από το pool (τοποθέτηση και έπειτα μηδενισμός της παραμέτρου) και στο κατηγόρημα που διασχίζει επιπλέον κάθε ερώτημα, ποτέ στον όγκο των δεδομένων.

Άνοιγμα και κλείσιμο ενός tenant

Δεν υπάρχει τίποτα να δημιουργηθεί από την πλευρά του διακομιστή: ένα tenant δεν είναι εγγραφή, είναι ο ακέραιος που φέρουν οι γραμμές του. Η βάση δεν έχει πίνακα tenants και ο δαίμονας δεν κρατά καμία λίστα — το άνοιγμα ενός λογαριασμού είναι η προσθήκη μιας εγγραφής στον πίνακα αντιστοίχισης του proxy, και η πρώτη εγγραφή του μέλους κάνει το tenant του να υπάρξει.

Ένα κενό tenant απαντά όπως μια κενή βάση, χωρίς σφάλμα: το metadata.counts επιστρέφει μηδενικά και οι λίστες δεν επιστρέφουν τίποτα.

Όταν ένα tenant καταργείται, το POST /ops/tenant.purge διαγράφει οριστικά όλα τα δεδομένα του (θέσεις, αγώνες, συλλογές, ιστορικό κ.λπ.) για το τρέχον tenant (αυτό που φέρει το X-Tenant-ID), καθώς και την κατάσταση συνεδρίας του (τελευταία αναζήτηση, τελευταία θέση, ανοιχτές καρτέλες — τις γραμμές του πίνακα session_state που φέρουν αυτό το tenant): η λειτουργία εκτελείται σε μία μοναδική συναλλαγή, είναι ιδεμποτική (κανένα σφάλμα κατά την εκκαθάριση ενός ήδη κενού tenant ή κατά την επανάληψη της κλήσης) και δεν επηρεάζει κανένα άλλο tenant. Σβήνει τις γραμμές αυτού του tenant σε όλους τους πίνακες που φέρουν ένα, και αφήνει μόνο ό,τι δεν ανήκει σε κανέναν: τον πίνακα metadata, με την καθολική του γραμμή έκδοσης σχήματος, και το ημερολόγιο των μεταναστεύσεων. Το εκκαθαρισμένο tenant ξαναγίνεται επομένως ακριβώς ένα κενό tenant, και ο ακέραιός του ξαναδίνεται. Είναι διαθέσιμη μόνο με το backend PostgreSQL — επιστρέφει σφάλμα invalid σε ένα backend SQLite, το οποίο δεν έχει έννοια tenant.

Συμπύκνωση και pool συνδέσεων

Το POST /ops/maintenance.vacuum συμπυκνώνει το αρχείο SQLite του δαίμονα — το αντίστοιχο του κουμπιού «Συμπύκνωση βάσης» της γραφικής διεπαφής και της εντολής blunderdb vacuum (βλ. Διεπαφή γραμμής εντολών (CLI)), με τον ίδιο έλεγχο χώρου δίσκου — και επιστρέφει τα μεγέθη πριν και μετά (sizeBefore, sizeAfter, σε bytes). Είναι διαθέσιμη μόνο με το backend SQLite· στην PostgreSQL, που δεν έχει αρχείο προς συμπύκνωση, επιστρέφει σφάλμα invalid.

Η δεξαμενή συνδέσεων PostgreSQL ρυθμίζεται μέσω μεταβλητών περιβάλλοντος: BLUNDERDB_POSTGRES_MAX_CONNS (50 από προεπιλογή), BLUNDERDB_POSTGRES_MIN_CONNS (5), BLUNDERDB_POSTGRES_MAX_CONN_LIFETIME (1h), BLUNDERDB_POSTGRES_HEALTH_CHECK_PERIOD (30s), BLUNDERDB_POSTGRES_CONNECT_TIMEOUT (5s — πέραν αυτού, μια μη προσβάσιμη βάση δεδομένων αποτυγχάνει γρήγορα αντί να παραμένει σε αναμονή στο χρονικό όριο TCP του λειτουργικού συστήματος) και BLUNDERDB_POSTGRES_MAX_CONN_IDLE_TIME (30m — μια σύνδεση που ανοίχθηκε για μια αιχμή κίνησης δεν παραμένει επ” αόριστον στη δεξαμενή μετά το πέρας της αιχμής). Κάθε τιμή είναι μια διάρκεια σε μορφή Go (5s, 30m, 1h)· αν λείπει ή είναι εσφαλμένη, χρησιμοποιείται η προεπιλογή. Όταν το --metrics είναι ενεργό, η κατάσταση της δεξαμενής εκτίθεται συνεχώς στο /metrics: blunderdb_pg_pool_acquired (συνδέσεις σε χρήση αυτή τη στιγμή), _idle (διαθέσιμες), _max (το ρυθμισμένο ανώτατο όριο) και _wait_count (ο αθροιστικός αριθμός κλήσεων Acquire που χρειάστηκε να περιμένουν μια ελεύθερη σύνδεση).

Μετανάστευση μιας βάσης SQLite προς PostgreSQL

Το blunderdb migrate αντιγράφει μια βάση SQLite ενός χρήστη σε ένα backend PostgreSQL, κάτω από έναν επιλεγμένο tenant — τον ακέραιο που ο reverse-proxy θα στέλνει στο X-Tenant-ID για αυτόν τον χρήστη — είναι ο δρόμος για να « ανεβάσετε » μια βιβλιοθήκη επιφάνειας εργασίας σε μια εγκατάσταση διακομιστή.

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

Η μετανάστευση αντιγράφει τις θέσεις, τις αναλύσεις και τα σχόλιά τους, τους αγώνες (παρτίδες + κινήσεις), τα τουρνουά (με τους συνδέσμους αγώνων τους) και τις συλλογές (με τη σύνθεσή τους), επανεκχωρώντας τα πρωτεύοντα και ξένα κλειδιά, όλα μέσα σε μια μοναδική συναλλαγή στην πλευρά προορισμού: η λειτουργία είναι ατομική (μια αποτυχία αφήνει τον προορισμό ανέπαφο, αρκεί να την ξεκινήσετε ξανά). Η πρόοδος και ο τελικός απολογισμός εκπέμπονται σε NDJSON στην τυπική έξοδο. Αν η βάση προέλευσης είναι αρκετά παλιά ώστε να χρειάζεται τη δική της επιτόπια αναβάθμιση σχήματος, αυτή εκτελείται πρώτη και εκπέμπει τα δικά της συμβάντα "schema-migration" (φάση/ολοκληρωμένα/σύνολο) πριν ξεκινήσει η αντιγραφή γραμμή προς γραμμή.

Επιλογή

Προεπιλογή

Σημασία

--from <uri>

–

βάση SQLite πηγής (sqlite:///<chemin> ή μια απλή διαδρομή)

--to <dsn>

–

DSN PostgreSQL προορισμού (postgres://…)

--tenant-id <n>

–

tenant προορισμού, ένας θετικός δεκαδικός ακέραιος (υποχρεωτικό εκτός από --dry-run · ένα όνομα όπως mon-tenant απορρίπτεται)

--dry-run

–

μετρά τι θα αντιγραφόταν χωρίς να γράψει τίποτα

--on-conflict <politique>

""

"" διακόπτει αν ο tenant έχει ήδη δεδομένα· το skip συγχωνεύει (αφαίρεση διπλότυπων θέσεων μέσω hash Zobrist)

Σημείωση

Δεν μεταναστεύονται (ακόμη) οι καταστάσεις της εφαρμογής: decks/κάρτες Anki, βιβλιοθήκη φίλτρων, ιστορικό αναζήτησης και εντολών, και μεταδεδομένα συνεδρίας. Η προτεραιότητα είναι η μετανάστευση της βιβλιοθήκης θέσεων και του ιστορικού αγώνων.

Ο γενικός dispatcher call

Συμπληρωματικά προς τις ιστορικές υποεντολές (Διεπαφή γραμμής εντολών (CLI)), το blunderdb call εκθέτει όλες τις λειτουργίες αποθήκευσης απευθείας, τοπικά. Περνά από τους ίδιους χειριστές με τον δαίμονα serve: η συμπεριφορά είναι επομένως πανομοιότυπη με το POST /v1/<famille>.<méthode>. Είναι χρήσιμο για το scripting και τις δοκιμές ολοκλήρωσης.

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

Επιλογές:

Επιλογή

Προεπιλογή

Σημασία

--db <chemin>

–

αρχείο SQLite (συντόμευση για --backend sqlite --dsn <chemin>)

--backend <type>

sqlite

sqlite ή postgres

--dsn <chaîne>

$BLUNDERDB_DSN

συμβολοσειρά σύνδεσης του backend

--scope <n>

1

tenant, ένας θετικός δεκαδικός ακέραιος (αποστέλλεται ως X-Tenant-ID · ένα όνομα όπως alice απορρίπτεται)

--json <chaîne>

{}

σώμα του αιτήματος σε μορφή JSON

--json-file <chemin>

–

διαβάζει το σώμα του αιτήματος από ένα αρχείο

--list

–

εμφανίζει όλες τις μεθόδους <famille>.<méthode> και τερματίζει

--if-match <version>

–

έκδοση που αποστέλλεται στο If-Match, απαιτούμενη από τις ενέργειες διεύθυνσης (Οι ενέργειες διεύθυνσης) και μεταγραφής (Μεταγραφή μέσω του API)

Το call εξυπηρετεί τις κινήσεις μεταγραφής χωρίς σημαία: δουλεύει σε τοπικό αρχείο, όπως το CLI. Κάθε κλήση είναι νέα διεργασία, άρα δική της συνεδρία: το sessionId μπορεί να παραλειφθεί και δεν υπάρχει αναίρεση από τη μία κλήση στην άλλη.

Η απάντηση JSON (ή η ροή NDJSON για τα σημεία πρόσβασης *.list) γράφεται στην τυπική έξοδο. Σε περίπτωση σφάλματος, η διεργασία τερματίζεται με μη μηδενικό κωδικό και ο φάκελος {"error":{…}} εκτυπώνεται στην τυπική έξοδο ώστε να παραμένει αναλύσιμος (για παράδειγμα με το jq). Μια απάντηση που φέρει την κεφαλίδα Direction-Version την τυπώνει στην έξοδο σφαλμάτων: είναι η τιμή που η επόμενη κίνηση περνά στο --if-match. Το call εξυπηρετεί τις κινήσεις διεύθυνσης χωρίς σημαία, όπως το CLI, αφού εκτελείται τοπικά.

Εργαλεία για έναν βοηθό ΤΝ (MCP)

Το blunderDB δεν ενσωματώνει κανένα γλωσσικό μοντέλο: προσφέρει τα εργαλεία του στον βοηθό που ήδη χρησιμοποιείτε (Claude Code, Claude Desktop, έναν τοπικό πελάτη), μέσω του Model Context Protocol. Ο βοηθός αναζητά, διαβάζει και εξηγεί· το blunderDB απαντά με τους δικούς του αριθμούς.

Τα εργαλεία περνούν από τους ίδιους χειριστές με τα /v1 και call:

Εργαλείο

Τι επιστρέφει

database_overview

πλήθη, περίοδος των αγώνων, έκδοση σχήματος, συχνοί παίκτες

search_positions

θέσεις μιας αναζήτησης στη γραμματική της γραμμής εντολών (περιγράφεται στο εργαλείο), με την κανονική της μορφή

search_comments, saved_searches

σχόλια που περιέχουν λέξεις· αποθηκευμένες αναζητήσεις

get_position

μια θέση, η ανάλυσή της (καλύτερες κινήσεις ή κύβος), η κίνηση που παίχτηκε και το σχόλιο

explain_error

το θέμα του λάθους, το κόστος του σε χιλιοστά πόντου και η καλύτερη απόφαση

similar_positions, decode_position, legal_moves, race_epc

γειτονικές θέσεις· ανάγνωση ενός XGID· νόμιμες κινήσεις· EPC αγώνα δρόμου

list_players, player_stats, recurring_errors, training_stats

παίκτες· συνολικό PR, πούλια, κύβος, ανά φάση· επαναλαμβανόμενα λάθη· PR του κουίζ και διατήρηση του Anki έναντι του πραγματικού PR

list_matches, get_match, list_tournaments

αγώνες, λεπτομέρειες ενός αγώνα, τουρνουά

list_collections, collection_positions, study_decks

συλλογές και οι θέσεις τους· πακέτα μελέτης

quiz_draw, quiz_grade

τραβά μια θέση χωρίς την απάντησή της και στη συνέχεια βαθμολογεί τη δοσμένη απάντηση

evaluate

αξιολόγηση gammonNet μιας θέσης που δίνεται ως κείμενο, χωρίς να την αποθηκεύει: καλύτερες κινήσεις ή απόφαση κύβου

anki_next

η επόμενη κάρτα προς επανάληψη ενός πακέτου επανάληψης

transcribe_list, transcribe_get, transcribe_mat

μεταγραφές αγώνων· λεπτομέρειες μιας μεταγραφής· το κείμενό της .mat

direction_list, direction_standings, direction_season

διευθυνόμενα τουρνουά· κατάταξη ενός τουρνουά· κατάταξη σεζόν

rollout

rollout μιας θέσης της βάσης: ισοτιμία, διάστημα 95 % και JSD ανά υποψήφιο

Μόνο πέντε εργαλεία γράφουν — save_position, comment_position, create_collection, add_to_collection και anki_review, που βαθμολογεί μια κάρτα που επιλέχθηκε από το anki_next — και προσφέρονται μόνο κατόπιν αιτήματος: --write τοπικά, --mcp-write στον δαίμονα. Όλα τα υπόλοιπα μόνο διαβάζουν· το rollout αποκτά όμως, όταν προσφέρεται η εγγραφή, το όρισμα store, που καταγράφει το rollout δίπλα στην ανάλυση της θέσης. Κανένα εργαλείο δεν διαγράφει τίποτα.

Τοπικά, ο βοηθός εκκινεί το blunderdb mcp πάνω σε ένα αρχείο (βλ. Διεπαφή γραμμής εντολών (CLI)). Για το Claude Code:

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

Στον δαίμονα, τα ίδια εργαλεία απαντούν μέσω HTTP στο POST /mcp (μεταφορά streamable HTTP, χωρίς συνεδρία). Όπως το /v1, το /mcp απαιτεί X-Tenant-ID και κάθε εργαλείο εργάζεται σε αυτόν τον tenant· ένα πρόγραμμα που ενσωματώνει το pkg/blunderdb/server το εξυπηρετεί επίσης. Ο δαίμονας δεν πιστοποιεί κανέναν (ADR-0005): το /mcp προστατεύεται στον proxy όπως το /v1, και το --mcp-write αποφασίζεται εκεί όπως το --direction. Κάθε κλήση /v1 που κάνει ένα εργαλείο περνά ξανά από όλη την αλυσίδα του δαίμονα: καταγράφεται, μετριέται στις μετρήσεις και χρεώνεται στο όριο ρυθμού του tenant, επιπλέον του αιτήματος /mcp που τη μεταφέρει. Μια κλήση εργαλείου κοστίζει λοιπόν πολλά αιτήματα· κανένα δεν εξαιρείται.

Όπως η call, η blunderdb mcp μεταφέρει το σχήμα μιας παλαιότερης βάσης κατά το άνοιγμα, ακόμη και χωρίς --write.