Modo headless (servidor)
Nota
Esta sección describe un modo avanzado y opcional de blunderDB, destinado a los despliegues en servidor, al uso multiusuario y a la automatización. El uso normal y recomendado de blunderDB sigue siendo la aplicación de escritorio descrita en los capítulos anteriores. Si utiliza blunderDB en solitario, en su propio ordenador, no necesita este modo: puede ignorar este capítulo sin perder ninguna de las funcionalidades de análisis.
Visión general
El mismo binario blunderdb puede, además de la aplicación de escritorio y de los comandos en línea (véase Interfaz de línea de comandos (CLI)), funcionar en modo headless: sin interfaz gráfica, controlado por completo desde la línea de comandos o a través de la red. Este modo agrupa tres usos:
el demonio
serve— expone el motor de blunderDB como un servicio HTTP + JSON, para ejecutar una base compartida en un servidor y acceder a ella entre varios usuarios;el despachador genérico
call— invoca cualquier operación de almacenamiento directamente, en local, para el scripting y las pruebas;el comando
migrate— transfiere una base SQLite de un solo usuario a un backend PostgreSQL multiusuario.
Estos tres usos se apoyan en una capa de almacenamiento común capaz de comunicarse con dos backends: SQLite (el formato de archivo .db habitual de la aplicación de escritorio) y PostgreSQL (para los despliegues en servidor multiusuario).
El demonio serve
blunderdb serve lanza el motor como un servicio HTTP que responde en JSON. Permite alojar una base de posiciones en una máquina y acceder a ella desde varios clientes.
# sqlite
blunderdb serve --db database.db --addr 127.0.0.1:8080
# postgres
blunderdb serve --backend postgres \
--dsn "postgres://user:pass@host:5432/blunderdb?sslmode=disable" \
--addr 127.0.0.1:8080
Nota
sslmode=disable solo conviene a una red privada de confianza — una base en un contenedor vecino, en una red sin ruta ni hacia el host ni hacia Internet. Para una base remota, sslmode=require cifra el enlace y verify-full verifica además el certificado del servidor y su nombre de host. Las demás cadenas de conexión de esta página llevan sslmode=disable por la misma razón: todas describen una red privada.
Advertencia
El demonio no realiza ninguna autenticación. Confía en la cabecera de petición X-Tenant-ID y debe ejecutarse detrás de un reverse-proxy (nginx, Caddy…) encargado de la autenticación. Nunca lo exponga directamente en la Internet pública.
X-Tenant-ID es el entero del tenant (1, 2, 42…): corresponde al reverse-proxy hacer corresponder la cuenta autenticada con ese entero. Un nombre (alice) se rechaza con 400 invalid, nunca se convierte.
Opciones:
Opción |
Por defecto |
Significado |
|---|---|---|
|
– |
archivo SQLite (atajo de |
|
|
backend de almacenamiento: |
|
|
cadena de conexión del backend |
|
|
dirección de escucha |
|
|
nivel de registro: |
|
|
expone |
|
|
sirve la página web de consulta en |
|
|
sirve los gestos de dirección de torneo y de evento; desactivados por defecto, véase Los gestos de dirección |
|
|
ofrece las herramientas de escritura de |
|
|
sirve los gestos de transcripción ( |
|
|
cierra una sesión de transcripción inactiva desde hace más tiempo |
|
– |
activa CORS para este origen, una lista de orígenes separados por comas, o |
|
|
límite de peticiones por segundo y por tenant (0 = desactivado); activado por defecto con un valor generoso en lugar de opcional, para que un fichero compose que solo piensa en la base de datos no herede un demonio sin ningún límite |
|
|
tamaño del cubo de tokens para los picos de peticiones |
|
|
posiciones que un tenant puede almacenar, verificadas al comienzo de una importación: una vez alcanzado el límite, se rechaza la importación (413, |
|
|
segundos de CPU de cálculo del motor por tenant y por día UTC (429, |
|
|
importaciones de un mismo tenant en curso a la vez (429, |
|
|
PostgreSQL: activa la Row-Level Security por tenant (defensa en profundidad, opcional) |
|
|
respeta la cabecera |
|
– |
base de bearoff de dos lados ( |
|
– |
directorio de la identidad de firma del demonio (creada en el primer uso); necesario para que |
|
– |
sirve la familia |
|
– |
expone |
La mayoría de las opciones también pueden proporcionarse mediante una variable de entorno (BLUNDERDB_BACKEND, BLUNDERDB_DSN, BLUNDERDB_ADDR, BLUNDERDB_LOG_LEVEL, BLUNDERDB_METRICS, BLUNDERDB_CORS_ALLOW_ORIGIN, BLUNDERDB_RATE_LIMIT_RPS, BLUNDERDB_RATE_LIMIT_BURST, BLUNDERDB_RLS, BLUNDERDB_READ_TENANTS, BLUNDERDB_TS_PATH, BLUNDERDB_IDENTITY_DIR, BLUNDERDB_OPS_ADDR, BLUNDERDB_PPROF_ADDR): un indicador explícito tiene prioridad sobre la variable correspondiente.
El demonio no tiene opción de directorio de datos: escribe sus tablas de bearoff en $XDG_DATA_HOME/blunderdb, o en su defecto ~/.local/share/blunderdb. Es por tanto XDG_DATA_HOME quien las desplaza — véase Las bases de bearoff.
La propia tabla de cubos del limitador de tasa lleva un límite estricto (10 000 tenants distintos): más allá de él, cada nuevo tenant expulsa el cubo menos recientemente utilizado en lugar de dejar que la tabla crezca sin límite — útil si un cliente envía muchos valores X-Tenant-ID distintos, voluntariamente o no, entre dos purgas periódicas de los cubos inactivos.
blunderdb serve rechaza ahora cualquier argumento posicional inesperado (más allá del único serve inicial que deja pasar un ENTRYPOINT ya reducido al binario desnudo): sin esta comprobación, un flag colocado después de tal argumento se ignoraba silenciosamente — docker run image serve --addr :9090, reflejo natural puesto que el ENTRYPOINT de la imagen ya vale serve, arrancaba en :8080 sin decir nada.
Puntos de acceso
El servicio expone puntos de acceso de explotación, siempre presentes:
GET /healthz— vivacidad (el proceso está en ejecución);GET /readyz— disponibilidad (el almacenamiento responde y su esquema está en la versión esperada);GET /metrics— métricas Prometheus (si--metricsestá activo);GET /app/— la página web de consulta (si--webestá activo).
La página web
blunderdb serve --web sirve una página en /app/: una biblioteca consultable desde una tableta o un teléfono, sin instalar nada.
Sabe hacer tres cosas, y esa lista es la decisión, no una etapa:
consultar una posición, su análisis y su tablero;
buscar, con la misma gramática de tokens que la línea de comandos de la aplicación;
repasar un mazo Anki — respuesta revelada y nota dada.
No sabe editar una posición, importar, borrar, gestionar colecciones, partidos, torneos ni la configuración, y no lo aprenderá. Una función que falta aquí no es una carencia: es el perímetro.
Está apagada por defecto, y ese valor por defecto es la decisión. El demonio no autentica a nadie: confía en la cabecera X-Tenant-ID y debe correr detrás de un proxy que autentique. Entregar una interfaz alcanzable por un navegador, encendida de fábrica, invitaría exactamente al despliegue que esa regla prohíbe.
La página no envía ningún tenant: es el proxy quien pone la cabecera, como para cualquier otro cliente. En desarrollo local, y solo ahí, /app/?tenant=1 permite nombrar uno — lo que no cambia nada sobre la seguridad de un demonio que ya acepta esa cabecera de cualquiera.
Los archivos de la página se sirven sin tenant, a propósito: un navegador debe poder cargar la página antes de que el proxy le asigne nada, y una página no contiene datos.
Vivacidad y disponibilidad responden a dos preguntas distintas. /healthz responde siempre 200 en cuanto el proceso sirve peticiones, sin consultar nunca el almacenamiento: un orquestador reinicia el contenedor cuya vivacidad falla, y una base momentáneamente inaccesible no debe reiniciar en bucle un demonio sano. /readyz responde 503 (con status a down o version_mismatch) mientras la base no responda o su esquema no sea el del binario: el tráfico simplemente se desvía hasta que vuelva.
El subcomando blunderdb healthcheck (presente también en el binario serve de la imagen de contenedor) realiza una petición GET /readyz al demonio local y devuelve 0 si está disponible, 1 si no; la dirección es la de --addr o de BLUNDERDB_ADDR, por defecto :8080. Es el HEALTHCHECK de la imagen Docker, y sirve igualmente en un script o en una unidad systemd:
blunderdb healthcheck --addr 127.0.0.1:8080 && echo ready
La superficie funcional sigue el esquema POST /v1/<familia>.<método> (por ejemplo /v1/positions.save, /v1/matches.get). Las familias cubren las posiciones, los análisis, los partidos, los comentarios, las colecciones, los torneos, las tarjetas Anki, los filtros, las sesiones, el historial (búsqueda y comandos), la búsqueda, los metadatos, los ajustes de biblioteca, las estadísticas, la importación y la exportación. Los endpoints de listado devuelven un flujo NDJSON (un objeto JSON por línea). El servidor se detiene limpiamente con SIGINT / SIGTERM.
Un error devuelve el sobre {"error":{"code":…,"message":…}}. El código not_found indica que un recurso nombrado no existe; unknown_route, también 404, indica que el demonio no sirve el método llamado: un cliente y un demonio de versiones distintas, o una familia que el demonio solo sirve con una opción. Un cliente solo concluye que falta un dato ante not_found.
positions.save devuelve {"id":…,"created":…}. created vale true solo para la llamada que insertó la posición, y lo dice la propia escritura: un cliente que copia una posición y luego su análisis, y debe deshacer la copia tras un fallo, solo borra la posición si la creó, sin la carrera de un positions.exists previo.
Lo que promete /v1
Un cliente escrito contra /v1 debe seguir funcionando. La regla cabe en tres líneas, y es más útil escrita que adivinada:
Lo que existe no cambia de sentido. Una ruta de
/v1no se renombra, ni se suprime, ni se resignifica. Un campo de petición o de respuesta no se renombra, ni se retira, ni cambia de tipo.Lo que se añade se añade. Una ruta nueva, un campo opcional de petición, un campo nuevo en una respuesta: un cliente que los ignora sigue funcionando, esa es la definición de «compatible» adoptada aquí. Un cliente debe pues ignorar los campos que no conoce en vez de rechazarlos.
El resto es
/v2. Hacer obligatorio un campo que no lo era, cambiar una unidad, cambiar el sentido de un código de error: son rupturas, y viven bajo otro prefijo, junto a/v1, el tiempo que tarden los clientes en cruzar.
Dos precisiones que importan. Las rutas /ops/ no están cubiertas: sirven para la explotación de un despliegue, cambian con él, y no son una API para programas de terceros. Y el contrato mismo se genera a partir de la tabla de rutas del demonio (openapi.yaml, Contrato de la API): no puede describir otra cosa que lo que el servidor sirve.
Transcribir por la API
La familia transcriptions.* permite a un cliente externo transcribir un partido gesto a gesto, con la misma lógica que el escritorio. Las lecturas (list, get, exportMat, losses) se sirven siempre. Los gestos (create, open, editMatch, apply, undo, redo, close, finish, abandon) solo se sirven con serve --transcription: sin este indicador, estas rutas responden 404.
create y open devuelven el estado del borrador, su revision y un sessionId. apply, undo, redo, close y finish nombran ese sessionId: ausente → 400, sesión caducada o desconocida → 410; el cliente reabre entonces el borrador (open), con el cursor al final del documento. abandon no nombra ninguna sesión: borra el borrador bajo la sola revisión de If-Match. Cada gesto que escribe lleva la última revisión vista en la cabecera If-Match y devuelve la siguiente:
If-Matchausente → 428;revisión obsoleta → 409; el sobre de error da la revisión actual (
details.revision) y el estado fresco del borrador (details.state: documento, revisión, sesión y cursor), que el cliente muestra antes de repetir su gesto si sigue vigente.
La revisión solo avanza cuando el documento cambia (cabecera y acciones): mover el cursor o introducir un dado de la acción en curso no escribe nada y devuelve la misma revisión. Una sesión es la del borrador, no la de un cliente: open devuelve la sesión viva cuando la hay, y las pestañas o puestos que la comparten comparten también el cursor y la pila de deshacer.
La sesión solo conserva la pila de deshacer, el cursor y la entrada en curso: el borrador se escribe tras cada gesto que lo cambia, de modo que una sesión perdida (inactividad, reinicio, otra instancia) no pierde ningún gesto. transcriptions.get devuelve la revisión como ETag y responde 304 a un If-None-Match que la nombra.
finish registra el Match y elimina el borrador, abandon lo elimina sin Match, close solo libera la sesión. editMatch abre un borrador sobre un Match existente y, para un partido importado, devuelve el recuento de análisis y comentarios que la transcripción no conserva (losses.lossy). El análisis del partido guardado se lanza con gammonnet.analyzeMissing.
Advertencia
El demonio no autentica a nadie: abrir la escritura es confiarla al proxy (Despliegue detrás de un proxy autenticador). Un rol «transcriptor» es una regla del proxy sobre el prefijo /v1/transcriptions., no una noción del demonio.
Un cliente Python
clients/python/ contiene un cliente mínimo, sin dependencia fuera de la biblioteca estándar — el demonio habla POST y JSON, lo que urllib y json cubren por completo:
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"])
Viene en dos mitades, y es deliberado. _generated.py lleva un método por ruta, generado a partir de la tabla de rutas del demonio con go run ./cmd/openapi-gen: una superficie escrita a mano derivaría el día en que se añade una ruta, y nadie lo notaría antes de que lo hiciera un usuario. client.py lleva el transporte — la sesión, la cabecera de tenant, el sobre de error, la lectura de NDJSON — y está escrito a mano. Lo que cambia con la API se genera; lo que cambia con el criterio, no.
Los nombres de método son familia_operación en snake_case: /v1/positions.loadByIds se convierte en positions_load_by_ids(). La familia se conserva porque varias familias comparten un nombre de operación (list, delete), y un list() a secas colisionaría.
events() sigue /v1/events y devuelve un diccionario por mensaje (véase Ser avisado de los gestos: /v1/events).
Un fallo lanza APIError, que lleva el sobre del demonio tal cual: el code (aquello sobre lo que ramifica un programa), el message (lo que lee una persona), el estado HTTP y los detalles.
Integrar el motor en un programa Go
pkg/blunderdb/server.Bootstrap abre el almacenamiento y devuelve un juego de manejadores en el proceso que llama, sin escuchar en un puerto. Es la puerta de entrada de un padre de confianza — gammonGo — que quiere la biblioteca de posiciones sin hacer correr un demonio al lado ni hablar HTTP consigo mismo.
Lo que eso supone es explícito: el padre es de confianza. No hay tenant que verificar, ni cabecera que validar, ni limitador de tasa — esas cosas pertenecen al demonio porque él da la cara a una red, y el ADR-0005 dice por qué. Un programa que integra el motor elige él mismo su tenant y responde de sus llamadas.
Dirección de torneos y eventos
Los torneos dirigidos en el puesto de trabajo y los eventos que los agrupan (rencontre en la API y sus rutas /v1/rencontres.*) se leen por la API, bajo el tenant del llamante, con el mismo código que el puesto de trabajo. La lectura siempre se sirve; los gestos (introducir un resultado, emparejar, crear un evento) solo se sirven con serve --direction (Los gestos de dirección).
directions.listydirections.directoryleen todo el tenant: la lista de torneos dirigidos, el directorio de jugadores.Las demás
directions.*toman{"tournamentId": N}:directions.get(la vista completa: propuestas, clasificación, partidas en curso),directions.participants,directions.freeParticipants,directions.tableGrid,directions.brackets,directions.standings,directions.standingsCsv,directions.history(filtros opcionalesplayerymatch),directions.clock,directions.slots,directions.lastDecision,directions.pageHtmlydirections.pairingSheetHtml(conround).rencontres.list, luegorencontres.getyrencontres.pageHtmlcon{"id": N}.rencontres.pageHtmlgenera la página mural de la sala, un documento HTML autónomo en el campohtml: una pantalla mural la muestra y la vuelve a leer periódicamente.rencontres.rankingdevuelve la clasificación de temporada, comoblunderdb tournament ranking --season:rencontreId,from,to,points,participationyelo, todos opcionales; sinrencontreIdni período, cuentan todos los torneos dirigidos del tenant.
Las páginas se generan en francés, el idioma del motor de dirección. Un torneo que no está dirigido, o que pertenece a otro tenant, responde 404.
Lecturas condicionales. Cada una de estas rutas devuelve una cabecera ETag. Reenviada en If-None-Match, obtiene 304 sin cuerpo mientras no haya cambiado nada de lo que la ruta lee. Toda escritura cambia el ETag de inmediato: un gesto en el torneo o en un torneo del mismo evento, la vinculación de una partida, un borrador iniciado desde una ranura, el cambio de nombre de un torneo, la modificación del evento. Responder 304 no repite ningún torneo, lo que hace barata una página mural que consulta cada pocos segundos. Solo lo que depende de la hora es una excepción: las propuestas, el reloj y las páginas se calculan en el momento de la lectura, y un ETag vale por tanto como máximo un minuto. Un cliente que vuelve a leer ve así pasar un plazo o una pausa dentro del minuto.
Estas rutas son POST. Para este verbo, la RFC 9110 (§13.1.2) responde 412 a un If-None-Match que se cumple. El demonio responde sin embargo 304: el cuerpo de la petición solo lleva los parámetros de una lectura sin efecto, que se comporta como un GET. La forma If-None-Match: * se rechaza (400), porque no designa ninguna respuesta que el cliente ya tendría. Una petición no válida (un round negativo, por ejemplo) se rechaza antes de cualquier condición.
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
Como el resto de /v1, estas rutas no autentican a nadie: detrás del proxy (Despliegue detrás de un proxy autenticador), cualquiera que alcance el prefijo /v1/directions. de un tenant lee sus torneos, incluidos los nombres de los jugadores. Un proxy que reserve estas lecturas a ciertos usuarios lo hace con una regla sobre ese prefijo y sobre /v1/rencontres..
Los gestos de dirección
blunderdb serve --direction abre los gestos que el puesto de trabajo realiza sobre un torneo dirigido y sobre un evento. Sin este indicador, estas rutas responden 404, como si no existieran. call siempre las sirve.
directions.create(tournamentId,config,seed),directions.setConfigydirections.previewConfig(config, la configuración en el formato JSON del motor);las inscripciones:
directions.enterParticipants(players),directions.addParticipant(name,club,rating; consectionykey, un rezagado ocupa una plaza de exención),directions.updateParticipant,directions.withdraw,directions.reinstate,directions.makeAbsent,directions.makeAvailable,directions.addPair,directions.updatePair;el desarrollo:
directions.confirmProposal(action, tal como la proponedirections.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;el evento:
rencontres.create,rencontres.update,rencontres.attach,rencontres.detach,rencontres.trash,rencontres.setTableOutOfService,rencontres.setBreaks;las propiedades de las mesas:
rencontres.setTables(id,tableSettings, una entrada por mesa que lleva alguna: número, nombre, sala, reservada, asignada a),rencontres.setEventRooms(id,tournamentId,rooms, las salas donde juega la prueba; ninguna significa todas las mesas) ydirections.setTables(tournamentId,tableSettings) para una prueba que juega sola.
Un gesto de torneo devuelve la vista completa del torneo, como directions.get; un gesto de evento devuelve el evento. A continuación el servicio reescribe las páginas de presentación en la carpeta que designa la base, como en el puesto de trabajo. Una página que no puede escribirse (carpeta desaparecida, disco lleno) no anula el gesto: la respuesta lleva una cabecera Direction-Page-Warning por cada página no escrita (tournament 3, rencontre 2), sin la ruta del servidor, y el puesto de trabajo la muestra en su barra de estado.
Un gesto que las reglas rechazan (nombre vacío, mesa ocupada, torneo que no ha empezado, configuración rechazada por el motor) devuelve 400 con el motivo. Una avería del demonio o de su base devuelve 500, sin detalle: el motivo queda en el registro del demonio.
Versión obligatoria. Toda lectura de un torneo o de un evento devuelve un encabezado Direction-Version, y todo gesto lo devuelve en If-Match:
sin
If-Match(o con*), el gesto se rechaza:428;si alguien ha escrito desde esa lectura, el gesto se rechaza:
409. El campodetailsdel error lleva el estado actual y suversion: el cliente vuelve a leer y reenvía su gesto si sigue siendo válido;en caso contrario el gesto se aplica y devuelve la nueva versión en
Direction-Version.
La comparación se hace dentro de la transacción del gesto, bajo un bloqueo de la base (bloqueo consultivo de PostgreSQL por torneo o por evento, bloqueo de escritura de SQLite): de dos gestos enviados sobre la misma lectura, solo uno se aplica, ya pasen por un mismo demonio, por dos demonios sobre una misma base PostgreSQL, o por el puesto de trabajo y call sobre un mismo archivo. El gesto se escribe por completo o no se escribe. Un torneo jugado en un evento tiene la versión de su evento, de modo que un gesto en una prueba hermana también la cambia. directions.create y rencontres.create no apuntan a nada existente y no llevan versión.
Idempotencia. Un gesto que lleva una cabecera Idempotency-Key se aplica una sola vez: reenviado con la misma clave, devuelve la primera respuesta, con sus cabeceras (Direction-Version incluida) e Idempotency-Replayed: true. Un doble clic o un reintento de red no introduce dos resultados; dos envíos simultáneos de la misma clave ejecutan el gesto una sola vez. Solo se conserva una respuesta satisfactoria.
La clave está ligada al cuerpo de la petición: la misma clave con otro cuerpo devuelve
422.La repetición va antes del control de versión: devuelve la respuesta conservada sin
428ni409, aunque la versión haya cambiado desde entonces.Las claves viven en memoria, en cada instancia del demonio, durante 24 horas, como máximo 1 000 por tenant: un reinicio las olvida, y otra instancia no las conoce.
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}'
Advertencia
El demonio no autentica a nadie (ADR-0005). Con --direction, cualquiera a quien el proxy deje pasar introduce resultados. El motor no conoce ningún rol (director, árbitro, lector): un rol es una regla del proxy, que reserva /v1/directions. y /v1/rencontres. a los directores, o solo deja pasar las lecturas. No lance nunca --direction en un demonio accesible sin ese proxy, ni siquiera en el wifi de un club.
Ser avisado de los gestos: /v1/events
GET /v1/events es un flujo Server-Sent Events (text/event-stream): un mensaje por gesto validado del tenant, publicado tras la escritura en la base, nunca por un gesto rechazado o cancelado. El mensaje dice qué ha cambiado y su nueva versión, no el estado: el cliente vuelve a leer lo que muestra, con If-None-Match.
event: rencontre—rencontreId,tournamentIds(las pruebas del evento, antes y después del gesto) yversion;event: direction—tournamentIdyversion, para un torneo jugado fuera de todo evento;event: transcription—transcriptionIdyrevision; un borrador abandonado o terminado llevaremoved(ymatchIdpara Terminar).
removed: true señala lo que ya no existe. La ruta solo se sirve con --direction o --transcription: sin ellos, el demonio no escribe nada que tuviera que anunciar, y /v1/events responde 404. Como toda ruta /v1/, exige X-Tenant-ID: un suscriptor solo oye su tenant. Un tenant mantiene como máximo 16 flujos abiertos a la vez; más allá, 429. El puesto de trabajo usa el mismo servicio pero no le conecta ningún bus: sus gestos no se anuncian.
Los parámetros tournament, rencontre y transcription (identificadores separados por comas, o repetidos) restringen la suscripción: un mensaje pasa si nombra a alguno de ellos. Un torneo de un evento recibe los mensajes de su evento. Un parámetro desconocido o un identificador no válido devuelve 400.
curl -N http://127.0.0.1:8080/v1/events?rencontre=2 -H 'X-Tenant-ID: 1'
Sin historial. El demonio no guarda ningún mensaje. Todo flujo se abre con event: resync, con un id: el cliente pudo perder gestos antes de conectarse, o entre dos conexiones, y vuelve a leer todo lo que muestra. El motivo es reconnected cuando la solicitud lleva Last-Event-ID, subscribed en caso contrario. Un suscriptor demasiado lento, cuya cola de 64 mensajes está llena, se desconecta tras el mismo resync: nunca retrasa un gesto. El flujo anuncia un plazo de reconexión de 3 segundos.
A través de un proxy. Se envía un comentario : ping cada 25 segundos para que un proxy no corte un flujo silencioso; X-Accel-Buffering: no pide a nginx que no lo almacene en búfer. El flujo no se comprime, escapa al tiempo de espera de las solicitudes ordinarias y cuenta como una sola solicitud para la limitación de tasa. La parada del demonio cierra todos los flujos; una suscripción solicitada durante la parada recibe 503.
Varias instancias. En SQLite, una sola instancia mantiene la base de datos: el bus en memoria basta. En PostgreSQL, en cuanto --direction o --transcription está activo, cada instancia retransmite sus acciones a las demás mediante LISTEN/NOTIFY, en el canal blunderdb_events: un suscriptor conectado a una instancia oye una acción validada en otra, o realizada mediante call sobre la misma base. El tenant viaja en la notificación, y la instancia que la recibe solo la entrega a los suscriptores de ese tenant. Cada instancia abre dos conexiones más (application_name blunderdb-events-… para la escucha, blunderdb-notify-… para el envío); una instancia que no puede escuchar al arrancar se niega a arrancar. call anuncia sin escuchar, y atiende su petición aunque no pueda anunciar.
Cualquier rol autorizado a conectarse puede emitir en este canal, también con --rls. Una notificación recibida solo se da por buena si su tenant es válido y su tipo conocido; el resto se registra en el diario y se ignora. En el peor de los casos, una notificación falsificada puede hacer que los suscriptores de un tenant vuelvan a leer sus datos.
La notificación se envía después de la escritura en la base, igual que el mensaje local. Quedan dos pérdidas sin
resync: una instancia terminada entre la escritura y la notificación, y una parada que no puede enviar en 2 segundos lo que queda en cola. La acción está validada, pero los flujos ya abiertos en las demás instancias solo la conocen cuando su cliente se reconecta.Una conexión de escucha perdida se restablece, con una espera creciente de 250 ms a 30 s. Las acciones de otras instancias ocurridas durante el corte se pierden: al reanudarse, cada suscriptor de la instancia recibe un
resynccon el motivomissed. Una notificación demasiado larga para PostgreSQL (8 000 bytes), o que una instancia no ha podido enviar, llega a las demás como ese mismoresyncpara el tenant afectado.Los
iddel flujo son propios de cada instancia. Un cliente que un balanceador de carga envía a otra instancia no obtiene nada de ellos: elresyncque abre todo flujo le hace volver a leer lo que muestra.
Las bases de bearoff
El demonio calcula sus dos tablas predeterminadas al arrancar, en segundo plano (TS-06-06 para el veredicto de cubo, OS-06 para el EPC): unos seis segundos de un núcleo, una vez, en su directorio de datos — $XDG_DATA_HOME/blunderdb, o en su defecto ~/.local/share/blunderdb. No se descarga nada ni hay nada incrustado en el binario (ADR-0027). Si esa carpeta es de solo lectura, las tablas se mantienen en memoria durante la vida del proceso: el servicio arranca, simplemente paga el cálculo en cada reinicio.
Un dominio más amplio no se calcula al arrancar — TS-06-11 pesa 1,2 GB y tarda minutos, y eso no es algo que un servicio decida solo. Corresponde al operador fabricarlo, con la CLI, en el volumen que el demonio leerá:
# 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
El primer lanzamiento deja que el demonio encuentre la tabla por sí solo en su directorio de datos; el segundo la designa por su ruta, esté donde esté. --data-dir es una opción de los subcomandos bearoff, nunca de serve.
blunderdb bearoff list --data-dir /srv/data/blunderdb dice qué contiene el volumen y qué costaría cada dominio; blunderdb bearoff verify sale con error ante una tabla corrupta, lo que lo convierte en una sonda de arranque utilizable tal cual. Véase Interfaz de línea de comandos (CLI) para el detalle.
Las rutas de explotación
Dos llamadas no se detienen en el tenant que las hace, y viven por tanto bajo un prefijo propio, POST /ops/<familia>.<método>:
/ops/maintenance.vacuum(backend SQLite) reescribe todo el archivo, incluidos los datos de todos los tenants, y mantiene un bloqueo de escritura mientras dura;/ops/tenant.purge(backend PostgreSQL) destruye los datos de un tenant, y el tenant destruido es el que nombra la cabecera que controla quien llama.
El demonio no autentica a nadie (véase más abajo): una ruta accesible para un tenant es una ruta que cualquier tenant puede llamar. El prefijo existe para que el proxy pueda rechazar ambas con una sola regla. Nunca exponga /ops/ a través del proxy público. En nginx, la regla cabe en una línea del bloque server; en Caddy, en dos líneas del sitio:
location /ops/ { return 403; }
location /metrics { return 403; }
@closed path /ops/* /metrics
respond @closed 403
La opción --ops-addr <host:puerto> va más allá: las dos rutas abandonan entonces la dirección --addr y solo se sirven en ese segundo escuchador, que conviene enlazar a una interfaz de administración. Sin la opción permanecen en el escuchador principal y bloquearlas es cosa del proxy.
Estas rutas exigen la cabecera X-Tenant-ID como todas las demás — una purga nombra al tenant que destruye, y la necesita más que ninguna otra. Solo las sondas (/healthz, /readyz) y /metrics prescinden de ella.
Por eso la regla de rechazo anterior cubre también /metrics: al no exigir ningún tenant, es legible por cualquiera que alcance al demonio, y publica el tamaño de la base y el trabajo en curso, todos los tenants confundidos. Se consulta desde la máquina del demonio, o por una ruta que el proxy reserva a la explotación. El tercer punto que nunca hay que exponer no es una ruta sino un escucha: el de --pprof-addr, que no tiene ninguna noción de tenant y entrega un perfil del proceso entero. Se vincula a una interfaz de administración, nunca publicada por el proxy.
Lo que no pasó a /ops/: /v1/gammonnet.sweepStale. La recuperación es costosa pero está acotada al tenant que llama; lo que la limita es el límite de tasa y los indicadores de trabajo en curso, no una frontera de confianza.
El contrato completo — cada método, su petición y su respuesta — se genera a partir del código fuente y se versiona: openapi.yaml en la raíz del repositorio (formato OpenAPI, esquemas incluidos) y su anexo legible, Contrato de la API (una tabla por familia). Ambos se regeneran con go run ./cmd/openapi-gen, y una prueba dedicada falla si alguno de los dos se queda atrás respecto a las rutas realmente registradas.
Cada solicitud /v1 acepta un cuerpo JSON (Content-Type: application/json, o ningún encabezado — un cuerpo de otro tipo se rechaza con 400 invalid en lugar de fallar con un mensaje de análisis JSON confuso); un método conocido invocado con el verbo HTTP equivocado responde 405, con el encabezado Allow nombrando el único verbo aceptado. Los métodos de listado que aceptan un limit rechazan más de 1000 filas por página (400 invalid) en lugar de aceptar un valor sin límite.
Todas las familias de listado aceptan limit y offset: positions.list, positions.listIds, matches.list, search.find, anki.reviewLog, comments.listAll, tournaments.list y collections.positions. Ambos valen cero por defecto, lo que significa lo que siempre ha significado: todo. No hay tope implícito — un flujo no se mantiene en memoria, así que una lista sin límite cuesta tiempo y ancho de banda pero nunca el equilibrio del demonio, mientras que un límite predeterminado silencioso haría que un cliente leyera una lista truncada creyéndola completa. Lo que aportan estos dos parámetros es la posibilidad de paginar, a quien lo quiera.
Cada conexión TCP tiene un límite de lectura/escritura por solicitud — un margen generoso para las llamadas ordinarias, mucho mayor para las rutas que transmiten (listas NDJSON, importaciones/exportaciones, el barrido de recuperación de gammonNet) — y el número de ellas abiertas a la vez está limitado: superado ese número, una conexión adicional espera a que se libere una de las existentes en lugar de que cada conexión reciba incondicionalmente su propio hilo de ejecución. Un cierre ordenado (SIGINT/SIGTERM) cancela primero toda importación y todo barrido de recuperación de gammonNet en curso — cada uno responde con un evento final {"event":"cancelled"} en lugar de que su conexión se corte sin explicación — antes de cerrar el servidor dentro del plazo de gracia habitual. El archivo temporal de una importación subida conserva de la extensión original solo las que el daemon reconoce (.xg, .xgp, .sgf, .mat, .bgf, .ogxm, .txt, .db, .dbx), y todas las importaciones simultáneas — de todos los tenants — comparten una cuota global de bytes volcados a disco: superada, una nueva importación se rechaza (too many requests) en lugar de dejar crecer sin límite la ocupación de $TMPDIR.
/v1/imports.json vuelve a leer una exportación JSON de blunderDB rellenando huecos: el análisis que lleva solo se escribe en una posición que aún no tiene ninguno, sin reemplazar nunca un análisis existente, y se conservan los rollouts de ambos lados.
La familia search ofrece tres puertas a la misma búsqueda. search.find toma el objeto de filtros completo, campo por campo. search.query toma una consulta escrita en el lenguaje de la barra de comandos de la aplicación (s cube p>30 E>50, descrito en Lista de comandos) y transmite las mismas posiciones; es la única forma de alcanzar, por la red, los filtros que no tienen campo evidente — patrón de movimiento, texto de comentario, jugador, fecha, dados excluidos, zonas y blots. search.parse no busca nada: responde qué significa una consulta — los filtros que denota, su forma canónica (dos consultas equivalentes la comparten, lo que hace comparable una búsqueda guardada) y sus diagnósticos.
Una consulta que lleva un componente que nada reconoce se rechaza (400 invalid, nombrando el componente) en vez de ejecutarse reduciendo la búsqueda en silencio. Un componente comprendido pero sin efecto aquí — x, que activa la estructura de exclusión, que es un tablero y no texto — viaja en la cabecera X-BlunderDB-Query-Diagnostics, de modo que el cuerpo siga siendo NDJSON de posiciones para todos los clientes existentes.
Dos métodos de la familia positions decodifican una posición sin guardarla: positions.fromXGID reconstruye una posición a partir de una cadena XGID, y positions.fromXGP a partir de un archivo de posición única .xgp.
POST /v1/exports.sqlite exporta todo el tenant actual — posiciones, colecciones, partidas, torneos, análisis, comentarios, jugadas realizadas, biblioteca de filtros y paquetes Anki — en un archivo SQLite que el puesto de trabajo puede abrir tal cual. El cuerpo JSON de la petición es opcional: watermarkOrigin / watermarkNote colocan una filigrana firmada con la identidad propia del demonio (--identity-dir) — sin estos campos, la exportación no lleva ninguna filigrana; pedirlos sin una identidad configurada falla con el código invalid. collectionIds restringe la exportación a esas colecciones y a sus posiciones, con análisis, comentarios y jugadas realizadas, sin la biblioteca de filtros ni los paquetes Anki.
Compartir una colección entre tenants pasa por el cliente, nunca por una lectura de un tenant en el otro: el tenant que da llama a exports.sqlite con collectionIds (y una filigrana, para que el receptor sepa de dónde viene el archivo), el tenant que recibe envía el archivo a imports.db. Cada petición lleva su propio X-Tenant-ID; el proxy decide quién tiene derecho a hacer una y otra. Al importar, una colección se une a la del mismo nombre del receptor, o se crea; sus posiciones se añaden al final, sin duplicados. Una colección viva del receptor no recibe ninguna posición: su consulta define su contenido. La importación de una base en la aplicación de escritorio sigue la misma regla.
curl -X POST http://127.0.0.1:8080/v1/exports.sqlite \
-H 'X-Tenant-ID: club-lyon' -H 'Content-Type: application/json' \
-d '{"collectionIds":[4],"watermarkOrigin":"Club de Lyon"}' -o ouvertures.db
curl -X POST http://127.0.0.1:8080/v1/imports.db \
-H 'X-Tenant-ID: alice' -F file=@ouvertures.db
La familia training mantiene el registro de la pestaña Entrenamiento: training.save añade una sesión (exercise, seedSource, recuentos, items) y devuelve su id (se acepta Idempotency-Key); training.sessions relee las sesiones, la más reciente primero (exercise y limit opcionales); training.numberStats agrega los ítems de un ejercicio por tipo de número. Las preguntas, en cambio, las sortea el cliente.
gammonnet.evaluate evalúa una posición desnuda (position o xgid), sin leer ni escribir nada en el tenant: con dados, las mejores jugadas (candidates, 5 por defecto, como máximo 20); sin dados, la decisión de cubo. ply va de 0 a 2 (2 por defecto); una búsqueda más profunda es trabajo de analyzeMissing.
La familia anki gana seis métodos que amplían el planificador de repetición espaciada (FSRS): anki.reviewLog (registro de cada revisión — calificación y resultado FSRS — para las estadísticas de retención y un historial fiel), anki.forecast (proyección del número de tarjetas que vencen en los próximos días, incluidas las atrasadas), anki.suspendCard / anki.buryCard / anki.removeCard (retirar una tarjeta de la cola de revisión temporal o definitivamente) y anki.retention (la tasa de acierto medida sobre las revisiones de un mazo, leída frente al objetivo que fijó su propietario).
Nota
anki.retention sustituye a anki.optimizeParams, que ajustaba el objetivo hacia la tasa observada y podía escribirlo. El objetivo de retención es una elección sobre el compromiso entre carga y calidad, la tasa medida es su resultado, y acoplar uno al otro es exactamente el mecanismo que los autores de FSRS descartan. El método solo mide, sin escribir jamás.
La familia stats ofrece stats.playerTable, que devuelve una fila de estadísticas por jugador (partidas, victorias/derrotas, decisiones contadas, PR global / fichas / cubo, Snowie Error Rate, errores, blunders y suerte) sobre las partidas que retiene el filtro transmitido. Como en la interfaz gráfica, esta tabla solo honra del filtro el periodo, los torneos y la longitud de las partidas: la selección de un jugador y el tipo de decisión se ignoran, ya que la tabla abarca a todos los jugadores y desglosa fichas y cubo en columnas distintas. El campo luck_known indica si la suerte se midió para ese jugador; luck_rate_mp no debe leerse cuando vale false, pues una suerte desconocida no es una suerte nula.
El filtro que se pasa a los métodos stats acepta, junto a PlayerName, un campo PlayerAliases: las demás grafías con las que ha firmado la misma persona. Como el nombre de un jugador se teclea a mano en cada archivo, una misma persona aparece a menudo bajo varias grafías, y un filtro que solo retiene una calcula sobre una parte de las partidas sin que nada parezca anómalo. El campo es puramente aditivo: se retienen las decisiones de cualquiera de los nombres. Fusionar los nombres en la base (MergePlayers) es la otra respuesta, reservada a las bases que no se han recibido de otra persona — reescribe las partidas de todos.
Dos métodos completan la paridad con la interfaz gráfica: stats.tournamentBadges devuelve, para cada torneo de la base, el indicador mostrado en su ficha (PR del jugador de referencia), y matches.findByHash indica si una partida ya está presente, a partir de las dos huellas de detección de duplicados — lo suficiente para evitar una importación redundante antes de emprenderla.
El campo winner de una partida, recibido por matches.createGame y devuelto por matches.games, tiene una sola codificación: 1 para el jugador 1, -1 para el jugador 2, 0 para una partida inacabada. Un cliente que aún envía 0, 1 o -1 en el sentido de gnubg (0 para el jugador 1, 1 para el jugador 2) registra el ganador opuesto.
analyses.repair recalcula las columnas desnormalizadas de un análisis (entre ellas cube_error) a partir de su análisis completo, y devuelve el número de filas realmente corregidas. Esas columnas no son más que una proyección: un error de proyección se repara, pues, sin reimportar los archivos de origen. La operación es explícita y nunca se dispara sola — ni al abrir una base ni por una migración, ya que el esquema no está en causa. Un análisis ilegible se deja tal cual en lugar de ponerse a cero. El caso conocido: los no-dobles etiquetados «Double No» por gnuBG, mal leídos antes de la versión 0.33.0 y que llevaban el error de un doble que nunca tuvo lugar.
gammonnet.analyzeMissing dispara la puesta al día gammonNet del tenant actual: escribir un análisis para cada posición que no tiene ninguno (ADR-0013, ADR-0015). Es una operación de biblioteca — lee y escribe posiciones y análisis almacenados — nunca un evaluador desnudo: blunderdb serve opera sobre una biblioteca, gammonnet serve evalúa una posición. La respuesta es un flujo NDJSON (started, progress, luego done o error/cancelled), con el mismo modelo que los puntos de acceso de importación; gammonnet.analyzeMissing.cancel (con el job_id recibido en el evento started) cancela una puesta al día en curso y sirve indistintamente para una puesta al día o una reanálisis (más abajo). Es la misma operación que el disparo automático tras la importación y el gesto explícito de la interfaz gráfica, y que el subcomando blunderdb analyze (véase Interfaz de línea de comandos (CLI)) — tres formas, una sola lógica.
gammonnet.sweepStale es la contrapartida de analyzeMissing para la reanálisis en lugar de la puesta al día: cada posición cuyo análisis proviene enteramente de gammonNet pero está obsoleto — una versión del motor más antigua que la que se ejecuta, o una profundidad distinta de ply — se reevalúa a la profundidad solicitada. El predicado de obsolescencia se comparte con el mismo lote de la interfaz gráfica y de blunderdb analyze --stale (ninguna lógica duplicada entre los tres modos); una posición que lleva un análisis de XG, GNUbg o BGBlitz nunca es tocada, sea cual sea su contenido de gammonNet — la protección de ADR-0013 sigue siendo incondicional. Misma forma NDJSON que analyzeMissing, y el evento final de cada una de las dos rutas lleva el desglose evaluated/refused/failed: una posición que gammonNet se niega a evaluar (una puntuación de partida fuera del alcance de su tabla, una decisión de doblaje que el modelo rechaza) cuenta como refused, no failed — nunca se reintenta en vano en el siguiente pase, a diferencia de una posición que realmente falló.
rollout.position juega una posición de la biblioteca (positionId) mediante un rollout y devuelve, para cada candidato, la equidad, su intervalo al 95 % y la JSD; rollout lleva los ajustes (fast, standard o standard,ply=1…), store registra el rollout terminado como un segundo análisis, junto al que lleva la posición, que nunca sustituye. Se rechaza una posición desnuda (un XGID): el demonio opera sobre una biblioteca. rollout.filter es la forma por lotes de blunderdb analyze --rollout: las posiciones que elige query (el lenguaje de la búsqueda) y que aún no llevan un rollout con los mismos ajustes se juegan una tras otra y se registran sobre la marcha, en un flujo NDJSON (started, progress tras cada serie de partidas, luego done, cancelled o quota_exceeded); rollout.filter.cancel lo cancela con su job_id. Un tenant solo ejecuta un lote a la vez, rollout o gammonNet. rollout.list lee los rollouts registrados de una posición.
Correlación y métricas de negocio
Cada petición recibe un identificador de correlación: el que el cliente (o un proxy inverso) envía en la cabecera X-Request-Id, o bien uno generado — en ambos casos devuelto en la misma cabecera de la respuesta y añadido a la línea de registro que cierra la petición (campo request_id). Un traceparent (W3C Trace Context) presente se retransmite tal cual en esa misma línea de registro: el demonio ni lo analiza ni lo valida, y no incorpora ninguna biblioteca de trazas; es un puente para correlacionar estos registros con una cadena de trazas que se ejecute aguas arriba, nada más.
Más allá del volumen de peticiones y su latencia, /metrics publica indicadores sobre el trabajo en curso, invisible de otro modo para una importación o un lote gammonNet bloqueados (una sola petición muy larga, no muchas peticiones):
blunderdb_imports_inflight— importaciones en curso, en todos los tenants;blunderdb_import_spool_bytes— bytes reservados actualmente sobre la cuota de spool de importación (véase--rate-limit-*más arriba para el equivalente en peticiones por segundo);blunderdb_gammonnet_sweep_inflight— barridos de recuperación de gammonNet en curso, en todos los tenants;blunderdb_database_size_bytes— tamaño del archivo SQLite principal, opg_database_sizeen PostgreSQL (la base entera, no por tenant, igual que los indicadores del grupo de conexiones más abajo); ausente mientras no se haya publicado ninguna medición.
Se puede obtener un perfil de memoria o CPU del proceso arrancando con --pprof-addr <host:puerto> (net/http/pprof): desactivado por defecto y deliberadamente en una dirección distinta de --addr, ya que estos puntos de acceso no tienen noción de tenant.
Compresión de los flujos
Las listas NDJSON repiten los mismos nombres de campo en cada línea. El demonio las comprime cuando el cliente lo acepta: envíe Accept-Encoding: gzip y la respuesta vuelve con Content-Encoding: gzip. Medido en una lista de partidas: 13,5 % del tamaño original con mil líneas, 14,6 % con cien.
La compresión no cambia nada al carácter incremental del flujo: cada registro se envía al cliente como antes, solo que comprimido por el camino. Se aplica únicamente a las respuestas NDJSON, JSON y texto: una exportación de base o un contenedor .dbx ya está comprimido, y volver a comprimirlo solo lo agrandaría. Accept-Encoding: gzip;q=0 la rechaza explícitamente.
Un solo tenant en SQLite
El backend SQLite no tiene columna de tenant: todos los datos están en las mismas tablas, sin tabique. En ese backend el demonio rechaza por tanto cualquier X-Tenant-ID distinto de 1 — aceptar los demás equivaldría a servir a cada uno las filas de todos tras una cabecera que afirma lo contrario. Un despliegue que realmente tenga varios tenants necesita el backend PostgreSQL.
Leer varios tenants
Un entrenador que lee las partidas de sus alumnos, un club que comparte una biblioteca: la relación entre estas cuentas reside en el host que las autentica, nunca en el demonio. El proxy la expresa mediante la cabecera X-Read-Tenants, una lista de tenants separados por comas (X-Read-Tenants: 2, 3), que coloca junto a X-Tenant-ID. El demonio confía en ella igual que en X-Tenant-ID y no autoriza nada por sí mismo (ADR-0063).
La función está desactivada por defecto, y desactivada significa rechazada: mientras el demonio no se inicie con --read-tenants (o BLUNDERDB_READ_TENANTS=true; Config.TrustReadTenants para un host que incorpora el motor), toda petición que lleve un X-Read-Tenants no vacío se rechaza (400), sea cual sea la ruta. Actívela solo cuando el proxy esté configurado para eliminar cualquier valor enviado por el cliente y para fijar él mismo la lista.
Solo las lecturas /v1/across.* miran esta cabecera. La lista completa: across.searchFind, across.matchesList, across.statsCompute y across.playerTable; leen primero X-Tenant-ID, luego cada tenant de la lista en el orden de la cabecera, como máximo 64 tenants distintos en total. Sobre un tenant de la lista, nombrado con el id: across.matchesGet, across.matchMovePositions (las posiciones de una partida, jugada a jugada) y across.analysesLoadByIds; un tenant ausente de la lista se rechaza allí. Cada resultado lleva su tenant de origen ("tenant": "2"), porque un id solo es único dentro de su tenant; una posición lleva además su hash Zobrist ("zobrist"), que designa el mismo tablero en todos los tenants. limit se aplica a cada tenant; 0 equivale a 1000, y un valor mayor se rechaza. En un flujo NDJSON, un error en un tenant tardío llega como última línea, tras los resultados de los tenants ya leídos: entonces falla todo el flujo.
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}'
Toda escritura permanece en X-Tenant-ID: ninguna otra ruta lee X-Read-Tenants. Sin la cabecera, una lectura across.* solo afecta a X-Tenant-ID. Una cabecera mal formada (un nombre, un elemento vacío, más de 64 tenants) o enviada en varias líneas rechaza la petición entera, sea cual sea la ruta. En SQLite, que solo tiene un tenant, la lista solo puede contener 1: la cabecera no amplía nada allí. Estas rutas son propias del servidor: la aplicación de escritorio y call solo tienen un tenant.
Una petición across.* cuesta hasta 64 lecturas en el almacenamiento, pero el límite de tasa (--rate-limit-rps) la cuenta una sola vez, para X-Tenant-ID: dimensione la base de datos y este límite en consecuencia, o haga que el proxy acote la lista. El registro de acceso de una ruta across.* incluye la lista recibida (campo read_tenants). La cabecera no figura entre las cabeceras CORS permitidas: solo la escribe el proxy, nunca un navegador.
Copia de seguridad y restauración
Cuatro gestos, según lo que se quiera recuperar.
Todo, bajo PostgreSQL — pg_dump es la herramienta, y blunderDB no tiene nada que añadir:
pg_dump --format=custom --file=blunderdb.dump "postgres://…"
pg_restore --dbname="postgres://…" blunderdb.dump
Todo, bajo SQLite en contenedor — el archivo se abre en modo WAL (el demonio codifica journal_mode(WAL) en su cadena de conexión, para todas las conexiones del pool): junto a blunderdb.db viven un -wal y un -shm, y las escrituras más recientes están en el -wal. Copiar solo el .db de un demonio en marcha da por tanto un archivo incompleto, sin que nada lo señale. Dos formas seguras:
detener el demonio, y luego copiar el volumen entero — al detenerse, los tres archivos son coherentes, y es el volumen, no solo el
.db, la unidad que hay que copiar;no copiar el archivo en absoluto:
/v1/exports.sqlite(más abajo) escribe un.dbcompleto mientras el demonio sigue funcionando, y es el único gesto que no exige ninguna interrupción.
/ops/maintenance.vacuum sí repliega el WAL en el archivo principal antes de reescribirlo, pero no congela la base: la siguiente escritura vuelve a caer en el WAL. Es un comando de compactación, no un método de copia de seguridad.
Un tenant solo — /v1/exports.sqlite escribe la base de un tenant en un archivo .db corriente, el que abre la aplicación de escritorio:
curl -X POST http://127.0.0.1:8080/v1/exports.sqlite \
-H "X-Tenant-ID: 42" -o tenant-42.db
Este comando se ejecuta en la máquina del demonio: apunta al escucha local, se salta el proxy, y por tanto plantea él mismo la cabecera del tenant. Desde el exterior, es al proxy a quien se interroga, y el tenant es el de la cuenta autenticada — la cabecera no hay que darla, el proxy borra la del cliente antes de inyectar la suya:
curl -u alice:… -X POST \
https://blunderdb.example.com/v1/exports.sqlite -o tenant-alice.db
Devolver ese archivo a su sitio — migrate lo copia bajo el tenant indicado:
./blunderdb migrate --from tenant-42.db --to "postgres://…" --tenant-id 42
migrate se niega a escribir en un tenant que ya contiene algo, y dice qué («128 posiciones, 3 partidas»); --on-conflict skip sigue adelante y deja que la deduplicación Zobrist fusione las posiciones.
Lo que migrate no copia, y anuncia al final con la cuenta exacta: los mazos Anki y sus cartas, la biblioteca de filtros, los historiales de búsqueda y de comandos, y el estado de sesión. Son datos de uso de la aplicación de escritorio; las posiciones a las que remiten sí se han movido.
Los umbrales de error y de blunder, en cambio, se copian: no son datos de uso sino el hábito de lectura del que dependen los recuentos, y un tenant que contara de otro modo que el archivo del que procede convertiría la migración en un cambio de sentido mudo.
El tenant ajusta los suyos mediante POST /v1/librarySettings.load y /v1/librarySettings.save. A diferencia de metadata, que es una infraestructura global expuesta en solo lectura, la tabla de los ajustes lleva un tenant_id y vive bajo Row-Level Security: un tenant que escribe sus umbrales no alcanza más que sus propias filas.
El puesto de trabajo y el servidor
La aplicación de escritorio abre archivos .db, no URL: no se conecta a ningún demonio serve, y no existe en ninguna parte un campo donde escribir una dirección. El servidor y el puesto de trabajo intercambian archivos, en dos gestos simétricos:
del servidor al puesto —
POST /v1/exports.sqliteescribe todo el tenant actual en un.dbque la aplicación de escritorio abre tal cual (véase Copia de seguridad y restauración);del puesto al servidor —
blunderdb migratecopia un.dbbajo el tenant deseado (véase Migrar una base SQLite a PostgreSQL).
No existe ninguna lectura entre tenants. El aislamiento es total: nada de lo que un tenant almacena es visible para otro, por ninguna ruta, y ninguna llamada toma un tenant como parámetro — cada solicitud solo conoce el que le ha planteado el proxy. Un entrenador que quiera ver las partidas de sus alumnos tiene por tanto dos caminos, ambos explícitos:
abrirle en el proxy una cuenta suplementaria, asociada al tenant del alumno: es la tabla de correspondencia del proxy, nunca el demonio, la que decide el tenant que ve una sesión;
pedirle una exportación — el
.dbproducido porexports.sqliteo por la ventana de exportación de la aplicación de escritorio — y abrirlo en su propio puesto.
Despliegue con Docker
El repositorio proporciona un Dockerfile.serve que construye una imagen de contenedor mínima del demonio: solo se compila el binario serve (Go puro, sin interfaz gráfica y sin CGO, por lo que está enlazado estáticamente), que luego se coloca en una imagen distroless.
# build
docker build -f Dockerfile.serve -t blunderdb-serve .
# run
docker run --rm -p 127.0.0.1:8080:8080 \
-e BLUNDERDB_DSN="postgres://user:pass@host:5432/blunderdb?sslmode=disable" \
blunderdb-serve
La construcción se lanza desde la raíz del repositorio, y el backend por defecto de la imagen es postgres.
La imagen escucha en el puerto 8080 y se configura mediante variables de entorno (BLUNDERDB_BACKEND, BLUNDERDB_DSN, BLUNDERDB_ADDR, BLUNDERDB_RLS). Declara un HEALTHCHECK que lanza cada 30 segundos blunderdb healthcheck (una petición a /readyz — la imagen distroless no tiene ni curl ni shell): docker ps muestra el estado healthy o unhealthy del contenedor, y Compose o un orquestador pueden esperar a que el demonio esté disponible antes de arrancar lo que depende de él.
Imagen publicada
No es necesario construir la imagen uno mismo: cada versión publicada de blunderDB sube la suya al registro de GitHub (GHCR), con el nombre ghcr.io/kevung/blunderdb-serve. Hay dos etiquetas disponibles: el número de la versión, fijado para siempre en esa imagen, y latest, que sigue la última versión publicada. Toda la documentación las anota ghcr.io/kevung/blunderdb-serve:<version>: es el número de una versión publicada el que ocupa el lugar de <version>, y es esa forma, nunca latest, la que fija un despliegue de producción. La imagen se ofrece para linux/amd64 y linux/arm64; Docker elige la arquitectura del host.
# pull
docker pull ghcr.io/kevung/blunderdb-serve:<version>
# postgres
docker run --rm -p 127.0.0.1:8080:8080 \
-e BLUNDERDB_DSN="postgres://user:pass@host:5432/blunderdb?sslmode=disable" \
ghcr.io/kevung/blunderdb-serve:<version>
# sqlite
docker run --rm -p 127.0.0.1:8080:8080 \
-v blunderdb-data:/data \
-e BLUNDERDB_BACKEND=sqlite -e BLUNDERDB_DSN=/data/blunderdb.db \
ghcr.io/kevung/blunderdb-serve:<version>
/data es el punto de montaje que la imagen prepara, con los permisos de su usuario no privilegiado, y su XDG_DATA_HOME: el volumen que se monta en él no sirve solo para la base, las tablas de bearoff se calculan allí una vez, en /data/blunderdb, y se recuperan en los siguientes arranques. Sin volumen, se recalculan en cada arranque del contenedor — unos segundos — y el demonio lo indica al arrancar si no puede escribirlas (could not prepare the bearoff tables; the exact regime will be unavailable), en cuyo caso sirve normalmente, con el único régimen estimado sobre las posiciones de salida.
La imagen lleva las etiquetas OCI habituales (org.opencontainers.image.source, .version, .revision, .licenses): docker inspect indica de qué commit y de qué versión proviene. Se construye mediante la integración continua a partir del Dockerfile.serve del repositorio, exactamente como arriba; construirla localmente o descargar la imagen publicada da el mismo binario.
Advertencia
Al igual que el propio demonio, el contenedor no realiza ninguna autenticación (ADR-0005): confía en la cabecera X-Tenant-ID tal como la recibe. Debe colocarse detrás de un reverse-proxy encargado de la autenticación, que fije él mismo esa cabecera, y no exponerse nunca directamente en la Internet pública. Los ejemplos anteriores publican el puerto solo en 127.0.0.1 por esta razón, y --addr se enlaza igualmente a 127.0.0.1: el proxy está en la misma máquina.
Despliegue detrás de un proxy autenticador
El ADR-0005 convierte al reverse-proxy en toda la frontera de seguridad del demonio: solo él autentica al que llama, solo él tiene derecho a fijar la cabecera X-Tenant-ID, y debe eliminar sistemáticamente cualquier valor enviado por el cliente antes de inyectar el tenant autenticado — de lo contrario, cualquiera puede hacerse pasar por cualquier tenant simplemente nombrándolo. El modelo de amenaza cabe en una frase: el demonio supone una red interna de confianza, y cualquiera que lo contacte directamente es, para él, el tenant que dice ser. El repositorio ofrece un ejemplo completo, listo para lanzar, en el directorio deploy/. Vive en el repositorio git, no en la imagen contenedor: hay que clonar el repositorio, o descargar los dos archivos reproducidos más abajo junto con deploy/.env.example en un mismo directorio.
El archivo Compose pone a Caddy — autenticación HTTP Basic de demostración — delante de blunderdb-serve y PostgreSQL, con Row-Level Security activada. Solo Caddy publica un puerto: los otros dos servicios viven en una red Docker declarada internal: true, sin ruta ni hacia el host ni hacia Internet, sean cuales sean los ports: que una modificación posterior les añadiera.
# 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
El Caddyfile autentica, asocia la cuenta autenticada con el entero del tenant (map), y luego lo inyecta en X-Tenant-ID después de haber borrado explícitamente cualquier valor recibido del cliente: la guardia header_up X-Tenant-ID "" precede a la inyección, de modo que una cabecera enviada por el cliente no puede alcanzar al demonio cualesquiera que sean las modificaciones posteriores del archivo.
Lo mismo ocurre con X-Read-Tenants (Leer varios tenants): el proxy elimina la del cliente y solo la coloca si conoce la relación entre las cuentas; los ejemplos del repositorio no conocen ninguna y siempre la eliminan.
# 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
}
}
Otros dos archivos completan el directorio: deploy/nginx-tenant-proxy.conf retoma el mismo esquema en un extracto de nginx (proxy_set_header X-Tenant-ID "" y luego proxy_set_header X-Tenant-ID $tenant_id, con el bloque map $remote_user $tenant_id), para quien ya tenga un nginx en marcha; deploy/README.md expone el modelo de amenaza y lo que nunca hay que hacer.
La autenticación HTTP Basic del Caddyfile es una demostración, no una recomendación de producción: se sustituye por forward_auth hacia un proveedor de identidad real (OIDC, SSO corporativo…), que autentica y luego transmite la identidad en el mismo punto del archivo. Las dos contraseñas y las dos cuentas de la tabla de correspondencia también hay que sustituirlas.
deploy/Caddyfile.oidc es la receta OpenID Connect: Caddy consulta a oauth2-proxy (forward_auth sobre /oauth2/auth), que responde 202 con la dirección de la cuenta conectada en X-Auth-Request-Email, o redirige a la página de inicio de sesión del proveedor. El bloque map asocia esa dirección al entero del tenant, y la misma guarda header_up X-Tenant-ID "" precede a la inyección. El servicio oauth2-proxy que se debe añadir al archivo Compose figura al principio del archivo.
Cuotas por tenant
Una instancia compartida limita lo que cada tenant le toma con --quota-positions, --quota-analysis-seconds y --quota-imports (sin opción, nada está limitado). El tiempo de cálculo cuenta cada cálculo del motor solicitado por el tenant: gammonnet.analyzeMissing, gammonnet.sweepStale, gammonnet.compare, gammonnet.cubeMatrix, gammonnet.evaluate, rollout.position y rollout.filter. Se cuenta en segundos de CPU: el tiempo transcurrido multiplicado por el número de búsquedas llevadas a cabo a la vez, de modo que un cálculo repartido entre todos los núcleos cuesta tanto como el mismo trabajo realizado posición por posición. Una vez agotado el tiempo del día, estas rutas responden 429 con el código quota_exceeded. Un barrido o un rollout.filter en curso conserva lo que ha registrado y termina con el evento quota_exceeded en lugar de done; un rollout.position interrumpido responde 429 y no registra nada; una comparación interrumpida devuelve lo que ha reunido con quotaExceeded: true y, en gathered, el número de posiciones que debía examinar. El contador vuelve a cero a medianoche UTC y vive en memoria: reiniciar el demonio lo pone a cero. La cuota de posiciones se verifica al comienzo de una importación, que no se interrumpe a mitad: un tenant puede superarla en lo que añadan sus importaciones en curso. positions.save y las demás escrituras individuales no la verifican. Cada rechazo lleva en details el límite (quota, limit) y el uso (used). tenants.quota devuelve al tenant que llama sus límites y su uso: posiciones almacenadas, segundos de cálculo del día, importaciones en curso.
Las cuotas son una contabilidad del demonio, no una frontera: se aplican al tenant que el proxy ha puesto en X-Tenant-ID.
Escenario completo, de cero a un demonio que responde:
git clone https://github.com/kevung/blunderDB.git
cd blunderDB/deploy
cp .env.example .env # POSTGRES_PASSWORD
docker compose up -d --build
# 401
curl -i http://localhost:8080/v1/metadata.counts -d '{}'
# 200
curl -u alice:demo-password http://localhost:8080/v1/metadata.counts -d '{}'
curl -u alice:demo-password -H "X-Tenant-ID: 999" \
http://localhost:8080/v1/metadata.counts -d '{}'
docker compose logs blunderdb-serve
docker compose down -v
La primera solicitud es rechazada por Caddy, antes incluso de alcanzar al demonio. Las dos siguientes se autentican como «alice», a quien la tabla de correspondencia asocia al tenant 1: devuelven el mismo cuerpo ({"positions":0,"analyses":0,"matches":0,…}) y el registro del demonio lleva tenant=1 tanto para una como para la otra — el valor 999 enviado por el cliente no sobrevivió a la guardia del Caddyfile. Este escenario se ha reproducido tal cual.
Para descargar la imagen publicada en lugar de construirla, sustituir en docker-compose.yml las tres líneas build: del servicio blunderdb-serve por una línea image:, y luego lanzar docker compose up -d sin --build:
blunderdb-serve:
image: ghcr.io/kevung/blunderdb-serve:<version>
restart: unless-stopped
El archivo Compose publica el puerto de Caddy en todas las interfaces (8080:80): es lo que se espera de un proxy, que está ahí para ser alcanzado. Lo que nunca debe publicarse es el demonio — y no lo está, no tiene ningún ports:.
Actualizar un despliegue
El esquema se migra automáticamente al arrancar, y esa migración es de sentido único: una base migrada a un esquema reciente ya no es legible por una versión anterior de blunderDB (véase Anexo: Esquema de la base de datos). El orden de los gestos importa, por tanto.
Hacer una copia de seguridad primero, antes que cualquier otra cosa: es el único paso atrás posible (véase Copia de seguridad y restauración).
Descargar la etiqueta de la versión deseada, nunca
latesten producción.latestsigue la última versión publicada: el despliegue que la fija cambia de versión según los reinicios, sin que se haya decidido, ni que la copia de seguridad del paso 1 sea forzosamente reciente.Reiniciar el demonio con la nueva imagen. Migra el esquema antes de servir la más mínima solicitud; si la migración falla, se detiene por el error en lugar de servir una base migrada a medias.
Verificar la sonda de disponibilidad.
GET /readyzresponde200y{"status":"ready","version":"…"}cuando el almacenamiento responde y su esquema es el del binario;503y{"status":"down"}cuando la base es inalcanzable;503y{"status":"version_mismatch","version":"…","expected":"…"}cuando los dos esquemas difieren — la respuesta nombra el de la base y el que el binario espera.blunderdb healthcheckdevuelve el mismo veredicto en código de retorno.
Un version_mismatch que persiste después del reinicio es la marcha atrás: un binario más antiguo frente a una base ya migrada. No existe migración descendente; es la copia de seguridad del paso 1 la que hay que restaurar.
Importante
Antes de activar --read-tenants en un despliegue existente, actualice el proxy: un proxy configurado antes de esta cabecera solo elimina X-Tenant-ID y transmitiría tal cual un X-Read-Tenants enviado por el cliente, que leería entonces otros tenants. Sin la opción, el demonio rechaza esta cabecera: un proxy que la deja pasar se nota por sus respuestas 400.
Backend PostgreSQL y multiusuario
Para un despliegue compartido, blunderDB puede almacenar los datos en PostgreSQL en lugar de en un archivo SQLite. El backend se selecciona mediante --backend postgres y la cadena de conexión --dsn. El esquema se crea y se migra automáticamente al arrancar.
Los datos están compartimentados por tenant (inquilino): cada petición lleva el identificador de su tenant (cabecera X-Tenant-ID, un entero decimal positivo como 1 o 42), lo que permite a varios usuarios compartir la misma instancia sin ver los datos de los demás. Un identificador que no sea tal entero — un nombre como alice o default, 0, 007 — se rechaza con 400 invalid: es el reverse-proxy quien asocia una cuenta a su entero, el demonio nunca adivina.
Row-Level Security
La opción --rls activa además la Row-Level Security de PostgreSQL. En cada arranque, el demonio instala en cada tabla que lleva un tenant_id una política tenant_isolation que solo deja pasar las filas del tenant nombrado por el parámetro de sesión current_setting('app.tenant_id'), y la fuerza hasta el propietario de la tabla (FORCE ROW LEVEL SECURITY). Este parámetro se fija en la conexión a su salida del pool y se restablece a su retorno; una conexión sin tenant no ve ninguna fila y no inserta ninguna. Es una defensa en profundidad opcional, desactivada por defecto: el filtrado por tenant del código de la aplicación permanece en su lugar en ambos casos.
El rol de conexión debe ser corriente: ni superusuario, ni
BYPASSRLS. PostgreSQL deja que esos dos atraviesen todas las políticas sin decir nada, y el aislamiento vuelve a depender solo del código de la aplicación. Ese mismo rol debe en cambio poseer las tablas, puesto que es él quien ejecuta losALTER TABLEy losCREATE POLICY.En una base ya poblada, no hay nada que migrar: la colocación de las políticas es DDL idempotente, ejecutado de nuevo en cada arranque tras la migración de esquema. Ningún dato se desplaza, ninguna fila se reescribe; activar o retirar
--rlses solo un reinicio.El coste está medido: en la lectura de una posición, 101,8 µs sin, 177,0 µs con, es decir +73,8 % — mismo contenedor, mismas filas, dos pools que solo difieren en este flag. Se paga en cada préstamo de conexión del pool (fijación y luego restablecimiento del parámetro) y en el predicado que cada solicitud atraviesa de más, nunca en el volumen de datos.
Abrir y cerrar un tenant
No hay nada que crear en el lado del servidor: un tenant no es un registro, es el entero que llevan sus filas. La base no tiene tabla de tenants y el demonio no lleva ninguna lista de ellos — abrir una cuenta es añadir una entrada a la tabla de correspondencia del proxy, y la primera escritura del miembro hace existir su tenant.
Un tenant vacío responde como una base vacía, sin error: metadata.counts devuelve ceros y las listas no devuelven nada.
Cuando se da de baja un tenant, POST /ops/tenant.purge elimina definitivamente todos sus datos (posiciones, partidas, colecciones, historial, etc.) del tenant actual (el indicado por X-Tenant-ID), así como su estado de sesión (última búsqueda, última posición, pestañas abiertas — las filas de la tabla session_state que llevan ese tenant): la operación se ejecuta en una única transacción, es idempotente (no produce error al purgar un tenant ya vacío ni al repetir la llamada) y no afecta a ningún otro tenant. Borra las filas de ese tenant en todas las tablas que llevan uno y no deja más que lo que no pertenece a nadie: la tabla metadata, con su fila global de versión de esquema, y el registro de migraciones. El tenant purgado vuelve así a ser exactamente un tenant vacío, y su entero se reasigna. Solo está disponible con el backend PostgreSQL — devuelve un error invalid en un backend SQLite, que no tiene noción de tenant.
Compactación y pool de conexiones
POST /ops/maintenance.vacuum compacta el archivo SQLite del daemon — el equivalente del botón «Compactar la base» de la interfaz gráfica y del comando blunderdb vacuum (véase Interfaz de línea de comandos (CLI)), con la misma comprobación de espacio en disco — y devuelve los tamaños antes y después (sizeBefore, sizeAfter, en bytes). Solo está disponible con el backend SQLite; en PostgreSQL, que no tiene archivo que compactar, devuelve un error invalid.
El pool de conexiones de PostgreSQL se ajusta mediante variables de entorno: BLUNDERDB_POSTGRES_MAX_CONNS (50 por defecto), BLUNDERDB_POSTGRES_MIN_CONNS (5), BLUNDERDB_POSTGRES_MAX_CONN_LIFETIME (1h), BLUNDERDB_POSTGRES_HEALTH_CHECK_PERIOD (30s), BLUNDERDB_POSTGRES_CONNECT_TIMEOUT (5s — superado ese tiempo, una base de datos inalcanzable falla rápido en lugar de bloquearse en el tiempo de espera TCP del sistema operativo) y BLUNDERDB_POSTGRES_MAX_CONN_IDLE_TIME (30m — una conexión abierta para un pico de tráfico no permanece indefinidamente en el pool una vez pasado el pico). Cada valor es una duración en formato Go (5s, 30m, 1h); ausente o inválida, se aplica su valor por defecto. Cuando --metrics está activo, el estado del pool se expone continuamente en /metrics: blunderdb_pg_pool_acquired (conexiones actualmente en uso), _idle (disponibles), _max (el límite configurado) y _wait_count (el número acumulado de llamadas Acquire que tuvieron que esperar una conexión libre).
Migrar una base SQLite a PostgreSQL
blunderdb migrate copia una base SQLite monousuario a un backend PostgreSQL, bajo un tenant elegido — el entero que el reverse-proxy enviará en X-Tenant-ID para ese usuario — es el camino para « subir » una biblioteca de escritorio a un despliegue en servidor.
blunderdb migrate \
--from sqlite:///path/to/database.db \
--to "postgres://user:pass@host:5432/db?sslmode=disable" \
--tenant-id 42
# --dry-run
blunderdb migrate --from sqlite:///path/to/database.db \
--tenant-id 42 --dry-run
La migración copia las posiciones, sus análisis y comentarios, las partidas (juegos + jugadas), los torneos (con sus vínculos de partida) y las colecciones (con su composición), reasignando las claves primarias y foráneas, todo ello en una única transacción del lado de destino: la operación es atómica (un fallo deja el destino intacto, basta con relanzarla). El progreso y el balance final se emiten en NDJSON por la salida estándar. Si la base de origen es lo bastante antigua como para necesitar su propia actualización de esquema in situ, esta se ejecuta primero y emite sus propios eventos "schema-migration" (fase/hecho/total) antes de que empiece la copia línea a línea.
Opción |
Por defecto |
Significado |
|---|---|---|
|
– |
base SQLite de origen ( |
|
– |
DSN PostgreSQL de destino ( |
|
– |
tenant de destino, un entero decimal positivo (obligatorio salvo en |
|
– |
cuenta lo que se copiaría sin escribir nada |
|
|
|
Nota
No se migran (todavía) los estados de la aplicación: mazos/tarjetas Anki, biblioteca de filtros, historial de búsqueda y de comandos, y metadatos de sesión. La prioridad es la migración de la biblioteca de posiciones y del historial de partidas.
El despachador genérico call
Como complemento de los subcomandos históricos (Interfaz de línea de comandos (CLI)), blunderdb call expone todas las operaciones de almacenamiento directamente, en local. Pasa por los mismos gestores que el demonio serve: el comportamiento es, por tanto, idéntico al de POST /v1/<famille>.<méthode>. Resulta útil para el scripting y las pruebas de integración.
# --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"}'
Opciones:
Opción |
Por defecto |
Significado |
|---|---|---|
|
– |
archivo SQLite (atajo de |
|
|
|
|
|
cadena de conexión del backend |
|
|
tenant, un entero decimal positivo (enviado como |
|
|
cuerpo de la petición en formato JSON |
|
– |
lee el cuerpo de la petición desde un archivo |
|
– |
muestra todos los métodos |
|
– |
versión enviada en |
call sirve los gestos de transcripción sin indicador: trabaja sobre un archivo local, como la CLI. Cada llamada es un proceso nuevo, y por tanto su propia sesión: el sessionId puede omitirse, y no se puede deshacer de una llamada a otra.
La respuesta JSON (o el flujo NDJSON para los endpoints *.list) se escribe en la salida estándar. En caso de error, el proceso termina con un código distinto de cero y la envoltura {"error":{…}} se imprime en la salida estándar para seguir siendo analizable (por ejemplo con jq). Una respuesta que lleva una cabecera Direction-Version la imprime en la salida de error: es el valor que el siguiente gesto pasa a --if-match. call sirve los gestos de dirección sin opción, como la CLI, ya que se ejecuta en local.
Herramientas para un asistente de IA (MCP)
blunderDB no incorpora ningún modelo de lenguaje: ofrece sus herramientas al asistente que ya usa (Claude Code, Claude Desktop, un cliente local), mediante el Model Context Protocol. El asistente busca, lee y explica; blunderDB responde con sus propias cifras.
Las herramientas pasan por los mismos manejadores que /v1 y call:
Herramienta |
Qué devuelve |
|---|---|
|
recuentos, período de las partidas, versión del esquema, jugadores frecuentes |
|
posiciones de una búsqueda en la gramática de la barra de comandos (descrita en la herramienta), con su forma canónica |
|
comentarios que contienen palabras; búsquedas guardadas |
|
una posición, su análisis (mejores jugadas o cubo), la jugada realizada y el comentario |
|
el tema del error, su coste en milipuntos y la mejor decisión |
|
posiciones vecinas; lectura de un XGID; jugadas legales; EPC de carrera |
|
jugadores; PR global, fichas, cubo, por fase; errores que se repiten; PR del quiz y retención de Anki frente al PR real |
|
partidas, detalle de una partida, torneos |
|
colecciones y sus posiciones; mazos de repaso |
|
saca una posición sin su respuesta y luego califica la respuesta dada |
|
evaluación gammonNet de una posición dada en texto, sin guardarla: mejores jugadas o decisión de cubo |
|
la próxima carta pendiente de un paquete de repaso |
|
transcripciones de partidas; detalle de una transcripción; su texto |
|
torneos dirigidos; clasificación de un torneo; clasificación de temporada |
|
rollout de una posición de la base: equidad, intervalo al 95 % y JSD por candidato |
Solo cinco herramientas escriben — save_position, comment_position, create_collection, add_to_collection y anki_review, que puntúa una carta sorteada por anki_next — y solo se ofrecen bajo petición: --write en local, --mcp-write en el demonio. Todas las demás solo leen; rollout gana sin embargo, cuando se ofrece la escritura, el argumento store, que guarda el rollout junto al análisis de la posición. Ninguna herramienta borra nada.
En local, el asistente lanza blunderdb mcp sobre un archivo (véase Interfaz de línea de comandos (CLI)). Para Claude Code:
claude mcp add blunderdb -- blunderdb mcp --db /chemin/vers/base.db
En el demonio, las mismas herramientas responden por HTTP en POST /mcp (transporte streamable HTTP, sin sesión). Como /v1, /mcp exige X-Tenant-ID y cada herramienta trabaja en ese tenant; un programa que incorpore pkg/blunderdb/server también lo sirve. El demonio no autentica a nadie (ADR-0005): /mcp se protege en el proxy como /v1, y --mcp-write se decide allí como --direction. Cada llamada /v1 que hace una herramienta vuelve a recorrer toda la cadena del demonio: se registra, se cuenta en las métricas y se imputa al límite de tasa del tenant, además de la petición /mcp que la lleva. Una llamada de herramienta cuesta, pues, varias peticiones; ninguna queda exenta.
Como call, blunderdb mcp migra el esquema de una base antigua al abrirla, incluso sin --write.