Saltar al contenido
Proyectos

Sistemas backend

Disponible ledgerline.adelsonaguasvivas.workers.dev

Ledgerline

Una API de eventos que conserva el orden, evita duplicados al reintentar y detecta cambios en el historial guardado.

ledgerline.adelsonaguasvivas.workers.dev
Demo de Ledgerline: cambiar el monto del evento 2 a 999 rompe la cadena de hashes e invalida el evento 3. Al restablecerla, la cadena vuelve a estar válida y en verde.
Demo en vivo: cambia un monto y /verify reporta brokenAt: 2.
Rol
Solo: diseño, código y publicación
Período
Desde jul 2026
Stack
TypeScript, Cloudflare Workers, Durable Objects, D1, Hono, Vitest, Miniflare

Disponible en ledgerline.adelsonaguasvivas.workers.dev. Código público en GitHub. Estado verificado el 22 oct de 2026.

En números

99

pruebas en 15 archivos, ejecutadas en Workers con Durable Objects reales y una base D1 local

12

casos de falla documentados, cada uno con su comportamiento esperado y una prueba

8

errores encontrados en revisión, cada uno corregido y cubierto por una prueba de regresión

1

dependencia de producción (Hono)

Fig. 1 Cómo se agrega un evento
  • Cliente
  • Worker Hono
  • Durable Object uno por flujo
  • D1 modelo de lectura
  1. Cliente a Worker 01 POST /v1/streams/:id/events Con el encabezado Idempotency-Key.
  2. Worker 02 Clave, límite de solicitudes y propietario Busca la clave de API por su hash SHA-256, consume un token del límite de solicitudes y verifica que el flujo pertenezca a esa clave. Si no quedan tokens, devuelve 429 con Retry-After.
  3. Worker a Durable Object 03 append(payload, key) Se dirige con idFromName(streamId).
  4. Durable Object 04 blockConcurrencyWhile Reutilizar una clave con el mismo cuerpo devuelve el {seq, hash} original. Un cuerpo distinto genera un conflicto. Una clave nueva recibe seq + 1 y el siguiente hash.
  5. Durable Object 05 Una sola escritura atómica Guarda juntos el evento, el registro de la clave, el resumen del minuto, la cabecera del flujo y la alarma del outbox. Se confirma todo o nada.
  6. Durable Object a Worker 06 {seq, hash}
  7. Worker a D1 07 INSERT OR IGNORE Copia el evento a D1 de inmediato para que las lecturas suelan ver la nueva escritura. Si falla, el cliente recibe de todos modos el resultado.
  8. Worker a Cliente 08 201 {seq, hash} Un reintento devuelve 200 con Idempotent-Replay: true. Un cuerpo distinto devuelve 422.
  9. Durable Object a D1 09 alarm(): barrido del outbox Tras unos 5 s, inserta las filas que faltan en D1. Reintenta cada 30 s mientras fallen las escrituras.

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 reconstruye la cadena desde el génesis y revisa cada seq, prevHash y hash. Al comparar el resultado con la cabecera guardada, también detecta eventos borrados al final.

La idea

Ledgerline es una API HTTP para libros contables, registros de auditoría y otros historiales que deben conservarse intactos. Solo permite agregar eventos. Los reintentos no duplican escrituras, la secuencia no tiene saltos y una cadena de hashes permite detectar ediciones, borrados o un historial recortado.

Lo que construí

Cada flujo de eventos tiene su propio Durable Object en Cloudflare Workers. Dentro de blockConcurrencyWhile, revisa la clave de idempotencia, asigna un número de secuencia y enlaza el evento a la cadena de hashes. Una escritura atómica guarda el evento, el registro de la clave, el resumen del minuto, la cabecera del flujo y una alarma de outbox. La operación se guarda completa o no escribe nada.

D1 mantiene un modelo de lectura para el historial paginado. El Worker copia cada evento de inmediato. Si una inserción falla, la copia se atrasa y la alarma del outbox sigue reintentando hasta que el evento esté en D1.

El hash de cada evento incluye su JSON canónico según RFC 8785, el hash anterior y su número de secuencia. Cada flujo conserva una versión de esa regla. Las claves de API se guardan como hashes SHA-256. Pedir el flujo de otra clave devuelve 404, y un límite de solicitudes por clave devuelve 429 con Retry-After.

Las 99 pruebas corren en Workers con Durable Objects reales y una base D1 local, incluidas fallas de D1 provocadas a propósito. CI las ejecuta en cada push a main y en cada pull request.

Retos técnicos

  1. 01 Reintentos sin escrituras duplicadas

    Las claves de idempotencia duran tanto como su flujo. Un reintento con el mismo cuerpo, comparado en forma canónica, devuelve el {seq, hash} original con Idempotent-Replay: true, sin importar cuánto tiempo haya pasado. Reutilizar la clave con otro cuerpo devuelve 422 y no escribe nada. Una prueba envía 5 solicitudes simultáneas con la misma clave y verifica que se cree un solo evento.

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

  2. 02 Conservar el orden entre llamadas await

    Un await dentro de un Durable Object, como una llamada a SHA-256, puede dejar que otra escritura empiece antes de que termine la primera. Ejecuto cada escritura dentro de blockConcurrencyWhile para conservar el orden. La prueba hace que el cálculo ceda el turno mientras se intercalan 20 escrituras y verifica que reciban los números 1 a 20. Sin esa protección, falla.

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

  3. 03 Actualizar la regla del hash

    Los ejemplos de prueba del propio RFC 8785 revelaron un error: el canonicalizador original ordenaba mal las claves numéricas como "9" y "10". Reemplazarlo habría hecho fallar /verify en flujos intactos con esas claves. Publiqué la versión 2 de la cadena. Cada flujo conserva su versión original, incluida en el hash génesis, para que los flujos de la versión 1 puedan seguir agregando eventos y verificándose.

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

Antes y después

AntesDespuésFuente
Reutilizar un Idempotency-Key con otro cuerpoDevolvía el evento original como un reintento exitoso.422 idempotency_key_reused, y no se escribe nada.Commit ff5270e, 9 de octubre de 2026
Entrega al modelo de lectura en D1Una sola inserción después de guardar; si fallaba, podía quedar un hueco permanente.Una alarma de outbox, programada en la misma escritura atómica, inserta las filas que faltan en D1 y reintenta cada 30 s hasta completar la entrega.Commits d2404dc y 4650d2a, 9 de octubre de 2026
JSON canónico para el hashSe reordenaban las claves antes de llamar a JSON.stringify, que altera el orden de las claves numéricas exigido por RFC 8785.RFC 8785 en la versión 2 de la cadena; los flujos de la versión 1 siguen verificándose.Commits e4db446 y a6923e9, 9 de octubre de 2026
Pruebas en el entorno de Workers69 en 14 archivos.99 en 15 archivos. CI falla si el README o la página publicada citan otro número.test/*.test.ts el 2 de agosto y el 9 de octubre de 2026

Lo próximo

Publicar puntos de control firmados de la cabecera de cada flujo fuera del servidor. Así, /verify podría empezar desde el último y detectar una reescritura completa y consistente del registro.

Sesión de terminal: se agrega un evento con Idempotency-Key, se reintenta y se recibe Idempotent-Replay: true. La verificación de la cadena devuelve valid: true.
Reintentar con la misma clave devuelve el resultado original. La cadena sigue siendo válida.
ledgerline.adelsonaguasvivas.workers.dev
Registro de reintentos de Ledgerline: la primera solicitud devuelve 201. Con el mismo Idempotency-Key y cuerpo, devuelve 200, Idempotent-Replay: true y los valores originales de seq y hash. Cambiar el cuerpo devuelve 422 idempotency_key_reused.

Pruébalo

Enfoques compartidos