Saltar al contenido
Proyectos

Sistemas backend

En vivo ledgerline.adelsonaguasvivas.workers.dev

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.

ledgerline.adelsonaguasvivas.workers.dev
Demo de Ledgerline: el monto del evento 2 cambia a 999, la cadena de hashes se rompe en el evento 2, el evento 3 queda inválido porque se apoya en ese enlace y al restablecer la cadena vuelve a verde.
Demo en vivo: cambia un monto y /verify reporta brokenAt: 2.
Rol
Solo: diseño, código y publicación
Tiempo
Desde jul 2026
Stack
TypeScript, Cloudflare Workers, Durable Objects, D1, Hono, Vitest, Miniflare

En vivo en ledgerline.adelsonaguasvivas.workers.dev. Código público en GitHub. Estado verificado el 15 oct de 2026.

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)

Fig. 1 El camino de un append
  • Cliente
  • Worker Hono
  • Durable Object uno por stream
  • D1 modelo de lectura
  1. Cliente a Worker 01 POST /v1/streams/:id/events con un Idempotency-Key
  2. Worker 02 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.
  3. Worker a Durable Object 03 append(payload, key) enrutado con idFromName(streamId)
  4. Durable Object 04 blockConcurrencyWhile 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.
  5. Durable Object 05 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.
  6. Durable Object a Worker 06 {seq, hash}
  7. Worker a D1 07 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.
  8. Worker a Cliente 08 201 {seq, hash} Un reintento recibe 200 con Idempotent-Replay: true; otro cuerpo recibe 422
  9. Durable Object a D1 09 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

  1. génesis SHA-256("ledgerline:v2:" + streamId)
  2. h1 evento 1
  3. h2 evento 2
  4. 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

  1. 01 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.

    Dónde está en el código: src/do/stream.ts (se abre en otra pestaña)

  2. 02 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.

    Dónde está en el código: test/concurrency.test.ts (se abre en otra pestaña)

  3. 03 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.

    Dónde está en el código: src/lib/hash.ts (se abre en otra pestaña)

Antes y después

AntesDespuésFuente
Reusar un Idempotency-Key con otro cuerpoSe 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 D1Un 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 hashLlaves 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 Workers69 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.

Una sesión de terminal: un append con Idempotency-Key, un reintento que devuelve Idempotent-Replay: true y luego una verificación que devuelve valid: true.
La misma clave dos veces devuelve el resultado original, y la cadena sigue verificando.
ledgerline.adelsonaguasvivas.workers.dev
Transcripción de reintentos de Ledgerline: el mismo Idempotency-Key enviado tres veces recibe 201, luego 200 con Idempotent-Replay: true y el mismo seq y hash, y luego 422 idempotency_key_reused cuando cambia el cuerpo.

Pruébalo

Relacionados por idea