Sistemas backend
Ledgerline
Una API HTTP en Cloudflare Workers para streams de eventos de solo anexar: appends exactamente una vez, orden sin huecos y una cadena de hashes SHA-256 que delata los cambios a la historia.
- Rol
- Tiempo
- Stack
- Enlaces
En números
99
pruebas en 15 archivos, ejecutadas en el runtime de Workers contra Durable Objects reales y un D1 local
12
modos de falla documentados, cada uno con lo que pasa y la prueba que lo fija
8
bugs encontrados en revisión, cada uno corregido y fijado con una prueba de regresión
1
dependencia en runtime (Hono)
- Cliente
- Worker
- Durable Object
- D1
- Cliente a Worker POST /v1
/streams con un Idempotency-Key/:id /events - Worker Llave, límite y dueño La llave de API se busca por su hash SHA-256, su token bucket gasta un token (429 con Retry-After si está vacío) y el stream tiene que ser de esa llave.
- Worker a Durable Object append(payload, key) enrutado con idFromName(streamId)
- Durable Object block
Concurrency Una llave conocida con el mismo cuerpo devuelve el {seq, hash} original; otro cuerpo es un conflicto. Si no, seq + 1 y el siguiente hash.While - Durable Object Una sola escritura atómica El evento, el registro de la llave, el rollup del minuto, la cabeza del stream y la alarma del outbox. Todo o nada.
- Durable Object a Worker {seq, hash}
- Worker a D1 INSERT OR IGNORE El camino rápido, para que una lectura justo después de escribir casi siempre lo vea. Si falla, el cliente igual recibe su respuesta.
- Worker a Cliente 201 {seq, hash} Un reintento recibe 200 con Idempotent-Replay: true; otro cuerpo recibe 422
- Durable Object a D1 alarm(): barrido del outbox Unos 5 s después inserta solo las filas que le faltan a D1, y reintenta cada 30 s mientras D1 falle.
Cadena de hashes
- génesis SHA-256("ledgerline:v2:" + stream
Id) - h1 evento 1
- h2 evento 2
- h3 evento 3
hn = SHA-256(hn-1 | JSON canónico RFC 8785 | n)
/verify recalcula la cadena desde el génesis, revisando cada seq, prevHash y hash, y después compara el resultado con la cabeza guardada, así que una cola borrada también falla.
La idea
Ledgerline es una API HTTP para streams de eventos de solo anexar: libros contables, registros de auditoría y otros historiales que no deben cambiar después. Un append reintentado cuenta una sola vez, cada stream mantiene un orden sin huecos y una cadena de hashes deja al descubierto una edición, un borrado o un corte posterior.
Lo que construí
Cada stream es un Durable Object en Cloudflare Workers. Dentro de blockConcurrencyWhile revisa la llave de idempotencia, asigna el siguiente número de secuencia, enlaza el evento a la cadena de hashes y guarda en una sola escritura atómica el evento, el registro de la llave, el rollup del minuto, la cabeza del stream y una alarma de outbox, así que un append se guarda completo o no se guarda. D1 tiene un modelo de lectura para el historial paginado: el Worker copia cada evento en el momento y la alarma del outbox entrega lo que esa copia no logró, así que el modelo puede atrasarse pero no perder un evento.
Cada evento se hashea sobre JSON canónico RFC 8785 junto con el hash anterior y su número de secuencia, y esa regla tiene una versión por stream. Las llaves de API se guardan solo como hashes SHA-256, pedir el stream de otra llave devuelve 404 y cada llave tiene su propio token bucket, que devuelve 429 con Retry-After. Las 99 pruebas corren en el runtime de Workers contra Durable Objects reales y un D1 local, con fallas de D1 inyectadas a propósito, y CI las corre en cada push a main y en cada pull request.
Lo difícil
-
Reintentos seguros, conflictos claros
Una llave de idempotencia se guarda mientras exista su stream. Un reintento con el mismo cuerpo, comparado en forma canónica, devuelve el {seq, hash} original con Idempotent-Replay: true, aunque pase mucho tiempo. Un reintento con otro cuerpo recibe 422 y no escribe nada, así un error del cliente nunca se reporta como éxito. Una prueba manda 5 solicitudes con la misma llave a la vez y espera un solo evento.
-
Orden a través de los await
Un Durable Object atiende una solicitud a la vez, pero un await, como el cálculo SHA-256, puede dejar que un segundo append empiece antes de que termine el primero. Cada append corre dentro de blockConcurrencyWhile. La prueba hace que el hash ceda el turno para que 20 appends se intercalen de verdad: reciben seq 1 a 20, y sin esa protección la prueba falla.
-
Corregir la regla del hash sin romper la historia
Las pruebas de conformidad con los ejemplos del propio RFC 8785 mostraron que el primer canonicalizador ordenaba mal las llaves numéricas como "9" y "10". Corregirlo en el mismo lugar habría hecho fallar /verify en streams intactos con esas llaves. El arreglo salió como la versión 2 de la cadena: cada stream conserva la versión con la que se creó, la versión forma parte del hash génesis y los streams de la versión 1 siguen agregando eventos y verificando.
Antes y después
| Antes | Después | Fuente | |
|---|---|---|---|
| Reusar un Idempotency-Key con otro cuerpo | Se respondía como una repetición exitosa del evento original. | 422 idempotency_key_reused, y no se escribe nada. | Commit ff5270e, 9 de octubre de 2026 |
| Entrega al modelo de lectura en D1 | Un solo insert después de guardar; si fallaba, podía quedar un hueco permanente. | Una alarma de outbox, armada en la misma escritura atómica, inserta solo las filas que le faltan a D1 y reintenta cada 30 s hasta que lleguen. | Commits d2404dc y 4650d2a, 9 de octubre de 2026 |
| JSON canónico para el hash | Llaves reordenadas y luego JSON.stringify, que deja las llaves numéricas fuera del orden de RFC 8785. | RFC 8785, publicado como la versión 2 de la cadena; los streams creados con la versión 1 siguen verificando. | Commits e4db446 y a6923e9, 9 de octubre de 2026 |
| Pruebas en el runtime de Workers | 69 en 14 archivos. | 99 en 15 archivos, y CI falla si el README o la página en vivo citan otro número. | test/*.test.ts el 2 de agosto y el 9 de octubre de 2026 |
Lo próximo
Checkpoints firmados de la cabeza del stream, publicados fuera del servidor, para que /verify empiece desde el último y también se detecte una reescritura completa y consistente del registro.
Pruébalo
Relacionados por idea
Pruebas contra el runtime real o los datos reales