Módulo 4: El modelo de datos del sistema

3. Diseñar un ledger de ejecuciones

Descripción

Al terminar esta lección vas a poder diseñar un run ledger —un registro de ejecuciones— en Postgres: qué columnas tiene, por qué cada una está ahí, qué estados representa el ciclo de vida de una ejecución (pendiente, hecho, fallido) y cómo se lee y se escribe desde n8n con el nodo Postgres. Vas a entender por qué el ledger es la fuente única de verdad de lo que ya pasó en tu sistema, y vas a ver el patrón de dos escrituras —registrar antes de actuar y actualizar después— que hace del ledger algo más que una bitácora: lo que lo vuelve la base de la recuperación ante fallos.

Esto importa porque el ledger es la pieza que convierte "mi workflow hace cosas" en "mi sistema sabe qué ha hecho". Sin él, cuando alguien pregunta "¿se procesó el pedido ORD-2041?", la única respuesta es abrir el historial de ejecuciones de n8n y leer a ojo. Con él, es una consulta: SELECT * FROM run_ledger WHERE order_id = 'ORD-2041'. La diferencia entre esas dos formas de responder es la diferencia entre operar por intuición y operar sobre datos, y es exactamente lo que se paga en un rol de dueño de sistema.

Conexión con el módulo: la lección 2 cerró la puerta a guardar la verdad dentro del workflow; esta abre la primera tabla que la guarda afuera. El ledger es la más completa de las dos estructuras que construyes en el módulo: registra la historia entera de cada ejecución. La lección 5 construirá su hermana filosa, la tienda de deduplicación, que guarda menos pero decide más rápido; y ahí verás que el ledger y la tienda no compiten sino que se complementan. La columna idempotency_key que diseñas aquí es la misma clave natural o sintética que trabajaste en el módulo 2; ahora le das un hogar persistente. Y el ciclo de estados —pendiente, hecho, fallido— es la base sobre la que el módulo 6 construirá los reintentos y la recuperación.

El libro de contabilidad

Vamos a empezar por la analogía, porque "ledger" es una palabra que quizás no usas todos los días y su origen explica todo lo que la tabla hace.

Un ledger es, literalmente, un libro de contabilidad: ese cuaderno donde un negocio anota, renglón por renglón, cada movimiento de dinero. Cada asiento tiene fecha, concepto, monto y un saldo. Y tiene una propiedad que lo define: los asientos no se borran. Si te equivocaste, no tachas el asiento viejo; escribes uno nuevo que corrige. El libro es un registro append-only —solo se agrega— porque su valor está justo en que conserva la historia completa. En cualquier momento puedes recorrerlo y responder: ¿esta factura ya se pagó? ¿cuándo? ¿por cuánto? El libro no es donde el dinero vive; es donde vive el relato de qué pasó con el dinero.

Un run ledger es exactamente eso, pero para las ejecuciones de tu sistema en vez de para el dinero. Cada vez que order-triage procesa un pedido, escribe un asiento: qué pedido, con qué clave, a qué hora, en qué estado terminó. Los asientos se acumulan. Y en cualquier momento puedes recorrer el libro y preguntar: ¿el pedido ORD-2041 ya se procesó? ¿cuándo? ¿le salió bien o falló?

Piénsalo también como el libro de visitas de una recepción. Cada persona que entra firma con su nombre y la hora. Al final del día, la recepción no tiene que recordar quién pasó: lo lee en el libro. Y si alguien pregunta "¿vino Laura hoy?", la respuesta no depende de la memoria de nadie —está escrita—. El run ledger es el libro de visitas de tus ejecuciones: cada una firma al entrar, y la verdad de quién pasó deja de depender de la memoria frágil de la ejecución para vivir en una página que no se borra.

Esta es la diferencia de fondo con la tienda de deduplicación que verás en la lección 5. La tienda responde una pregunta binaria y rápida —"¿ya vi esta clave? sí/no"— y guarda lo mínimo. El ledger responde preguntas ricas —"¿qué pasó con esta ejecución, cuándo, con qué resultado?"— y guarda la historia. Las dos son útiles; sirven para cosas distintas.

Anatomía del run ledger: qué columna, y por qué

Diseñar la tabla es decidir qué preguntas quieres poder responderle. Vamos columna por columna, porque cada una está para contestar algo específico. Recuerda que todos los identificadores —nombres de tabla y de columna— van en inglés, como en cualquier equipo técnico real; la prosa que los explica va en español.

ColumnaTipoPara qué está
idBIGSERIALUn identificador propio de cada asiento, que la base asigna sola y nunca se repite
idempotency_keyTEXT (única)La clave que identifica de forma única qué trabajo representa este asiento. El corazón de todo
order_idTEXTEl pedido de Cumbre al que corresponde, para poder buscar por él de forma legible
statusTEXTEn qué punto del ciclo está: pending, done o failed
resultJSONBEl resultado de la ejecución: el id del cobro creado, el mensaje de error, lo que quieras recordar
created_atTIMESTAMPTZCuándo se registró el asiento (cuándo empezó el trabajo)
updated_atTIMESTAMPTZCuándo se actualizó por última vez (cuándo terminó, o cuándo cambió de estado)

Vamos a desmenuzar las que tienen miga.

idempotency_key es la columna central, y es única. Esto merece detenerse. En el módulo 2 aprendiste que una clave de idempotencia identifica el trabajo, no el intento: dos disparos del mismo pedido comparten la misma clave, aunque sean dos ejecuciones distintas. Aquí esa clave se vuelve una columna, y le pones una restricción de unicidad: le dices a Postgres "en esta tabla no puede haber dos filas con la misma idempotency_key, jamás". Esa restricción es lo que convierte al ledger en un guardián: si intentas registrar un asiento con una clave que ya existe, la base lo rechaza. Para order-triage, la clave puede ser el propio order_id (una clave natural) o un hash que combine varios campos (una clave sintética), según lo que decidiste en el módulo 2. La tabla no cambia; solo lo que metes en esa columna.

status cuenta el ciclo de vida. Una ejecución no es un instante, es un proceso con principio y fin, y entre esos dos puntos puede pasar de todo —justo el momento en que la red se cae o el CRM tarda—. Por eso el ledger no guarda solo "pasó" o "no pasó", sino en qué punto del camino está:

  • pending — el trabajo empezó pero todavía no terminó. El asiento se escribe con este estado antes de crear el cobro.
  • done — el trabajo terminó bien. Se actualiza a este estado después de que el cobro se creó con éxito.
  • failed — el trabajo empezó pero falló. Se actualiza a este estado si algo se rompió en el camino.

Esos tres estados son un modelo mínimo, y son suficientes para todo el módulo. La razón por la que hay tres y no dos —por la que pending merece su propio estado— es el hilo que conecta esta lección con el módulo 6, y lo desarrollamos en la sección de profundización.

result es de tipo JSONB, y esa elección es deliberada. JSONB es el tipo de Postgres para guardar un objeto JSON completo dentro de una sola columna. Piénsalo como un bolsillo flexible: en vez de crear una columna para el id del cobro, otra para el mensaje de error, otra para cada cosa que se te ocurra guardar, metes un objeto con lo que corresponda en cada caso. Para un asiento done, result podría ser {"charge_id": "CHG-9981", "amount": 1734}. Para uno failed, {"error": "CRM respondió 503", "retryable": true}. Un solo bolsillo, contenido distinto según el desenlace. Es la forma pragmática de que el ledger recuerde qué pasó sin tener que rediseñar la tabla cada vez que quieras guardar un dato nuevo.

created_at y updated_at son marcas de tiempo con zona horaria. El tipo TIMESTAMPTZ guarda el instante y su zona horaria, lo que evita el clásico enredo de "¿esta hora es de México o del servidor?". created_at marca cuándo nació el asiento; updated_at, cuándo se tocó por última vez. Con esas dos puedes responder preguntas de operación reales: ¿cuánto tardó esta ejecución entre que empezó y terminó? ¿hay asientos que quedaron en pending desde hace una hora, lo que sugiere que se colgaron?

El SQL que crea la tabla

Con el diseño claro, esta es la sentencia que crea el ledger. La vas a ejecutar una sola vez, cuando montas el sistema. No te asustes si el SQL te resulta nuevo; vamos a leerla línea por línea justo después.

CREATE TABLE IF NOT EXISTS run_ledger (
  id               BIGSERIAL     PRIMARY KEY,
  idempotency_key  TEXT          NOT NULL UNIQUE,
  order_id         TEXT          NOT NULL,
  status           TEXT          NOT NULL DEFAULT 'pending',
  result           JSONB,
  created_at       TIMESTAMPTZ   NOT NULL DEFAULT now(),
  updated_at       TIMESTAMPTZ   NOT NULL DEFAULT now()
);

Vamos a desarmarla, porque cada palabra hace un trabajo:

  • CREATE TABLE IF NOT EXISTS run_ledger — crea una tabla llamada run_ledger. El IF NOT EXISTS es una cortesía importante: si la tabla ya existe, no falla ni la borra; simplemente no hace nada. Eso te deja correr esta sentencia sin miedo, aunque no recuerdes si ya la creaste.
  • id BIGSERIAL PRIMARY KEYBIGSERIAL le dice a Postgres "esta columna es un número que tú asignas solo, incrementándolo con cada fila nueva". PRIMARY KEY significa "esta columna identifica de forma única cada fila y es el índice principal de la tabla". Es el número de asiento del libro contable, que la base lleva por ti.
  • idempotency_key TEXT NOT NULL UNIQUE — texto (TEXT), obligatorio (NOT NULL, no admite vacío) y único (UNIQUE, no se puede repetir). Esta es la línea que convierte al ledger en guardián: dos asientos con la misma clave son imposibles.
  • order_id TEXT NOT NULL — el pedido, obligatorio, para poder buscar de forma legible por humanos.
  • status TEXT NOT NULL DEFAULT 'pending' — el estado, obligatorio, y con un valor por defecto: si insertas una fila sin especificar status, Postgres pone 'pending' automáticamente. Es un detalle cómodo, porque el estado inicial de todo asiento es justamente pending.
  • result JSONB — el bolsillo flexible para el resultado. Fíjate en que no dice NOT NULL: un asiento recién nacido, todavía en pending, aún no tiene resultado, así que se permite vacío (NULL).
  • created_at ... DEFAULT now() y updated_at ... DEFAULT now() — las dos marcas de tiempo, y now() es una función de Postgres que devuelve el instante actual. Con el DEFAULT, no tienes que calcular la hora tú: la base la pone al insertar.

Una nota de honestidad: hay más de una forma buena de escribir esto. Algunos equipos añaden una columna workflow_name, otros usan un UUID en vez de BIGSERIAL, otros separan created_at y finished_at. No hay una única tabla correcta; hay una tabla que responde bien las preguntas que a ti te importan. Esta responde las del módulo. Cuando la adaptes a tu sistema, la pregunta guía es siempre la misma: ¿qué voy a necesitar preguntarle a este libro dentro de tres meses?

El patrón de las dos escrituras

Aquí está la idea que hace del ledger algo vivo y no una simple bitácora que llenas al final. El asiento no se escribe una vez; se escribe dos. Y el orden importa.

Escritura 1 — al empezar, en estado pending, antes del efecto. En cuanto order-triage recibe un pedido y decide procesarlo, antes de llamar al CRM, escribe el asiento: "estoy empezando a procesar ORD-2041, clave tal, estado pending". La ejecución declara su intención antes de actuar.

Escritura 2 — al terminar, actualizando a done o failed, después del efecto. Cuando el cobro se creó bien, actualiza el mismo asiento: estado done, y en result guarda el id del cobro. Si algo falló, lo actualiza a failed con el error.

Recibe ORD-2041
   │
   ▼
[Escritura 1]  INSERT en run_ledger  →  status = 'pending'      (antes del efecto)
   │
   ▼
HTTP Request: "Create charge in CRM"  →  crea el cobro          (el efecto)
   │
   ▼
[Escritura 2]  UPDATE en run_ledger  →  status = 'done',        (después del efecto)
                                         result = { charge_id }

¿Por qué en ese orden, y no simplemente anotar todo al final? Porque el orden es lo que te protege del peor momento: cuando algo se rompe justo entre el efecto y el registro. Imagina que anotaras el asiento después de crear el cobro, y que la ejecución se cayera después de crear el cobro pero antes de anotar. El cobro existiría en el CRM, pero tu ledger no tendría ni rastro de él: la verdad y la realidad quedarían desincronizadas, y tú del lado ciego. Al escribir pending antes del efecto, garantizas que nunca hay un efecto sin un asiento que lo mencione. En el peor caso, tendrás un asiento en pending que quedó a medias —y eso es información: sabes que ese pedido empezó a procesarse y no sabes si terminó, que es exactamente lo que quieres investigar—. Un asiento colgado en pending es un problema que ves; un cobro sin asiento es un problema invisible.

Esa es la razón de fondo por la que pending merece ser un estado propio y no un simple "aún no existe la fila". El estado pending es la afirmación "esto empezó y no sé cómo terminó", y poder distinguir eso de "esto nunca empezó" es la base de toda la recuperación ante fallos que verás en el módulo 6. Un sistema que solo tiene "hecho" y "no hecho" no puede recuperarse de una caída a media ejecución, porque no sabe qué quedó a la mitad.

Ejemplo trabajado: order-triage escribe su asiento

Veamos el patrón completo en el workflow, con los nodos concretos. Recuerda una restricción clave de n8n 2.0: las escrituras a Postgres las hace el nodo Postgres, no un nodo Code. El nodo Code puede calcular la idempotency_key, pero quien habla con la base de datos es el nodo dedicado.

El workflow queda así:

Webhook
  └─► Code: "Compute idempotency key"        ← calcula la clave con crypto
        └─► Postgres: "Ledger — insert pending"   ← Escritura 1 (Insert)
              └─► AI Agent: "Classify order"
                    └─► HTTP Request: "Create charge in CRM"   ← el efecto
                          └─► Postgres: "Ledger — mark done"   ← Escritura 2 (Update)

El nodo Code que calcula la clave puede usar crypto, que sí está permitido en el nodo Code de n8n 2.0 (a diferencia de fetch o axios, que no):

// ============================================================
// Nodo: Code — "Compute idempotency key"
// Modo: Run Once for Each Item
//
// ENTRADA:  un pedido de Cumbre con order_id
// SALIDA:   el mismo item, con una idempotency_key calculada
// NOTA:     crypto SÍ está disponible en el nodo Code de n8n 2.0.
//           No hacemos HTTP ni tocamos la base de datos aquí:
//           de eso se encargan los nodos dedicados.
// ============================================================

const crypto = require('crypto');

const order = $input.item.json;

// Clave sintética: hash de order_id + total, para que un mismo pedido
// con el mismo contenido produzca siempre la misma clave.
// (Si tu clave natural, el order_id solo, ya es única y estable,
//  puedes usarla directo. Ver módulo 2.)
const raw = `${order.order_id}:${order.order_total}`;
const idempotencyKey = crypto.createHash('sha256').update(raw).digest('hex');

return {
  json: {
    ...order,
    idempotency_key: idempotencyKey,
  },
};

La Escritura 1 la hace un nodo Postgres en operación Insert, apuntando a la tabla run_ledger, con los campos idempotency_key, order_id y status = 'pending'. La Escritura 2 la hace otro nodo Postgres en operación Update, que busca el asiento por su idempotency_key y le pone status = 'done' y el result.

Qué esperar. Con un pedido ORD-2041 que entra por primera vez, verás en la tabla run_ledger, justo después del primer nodo Postgres, una fila con status = 'pending', su created_at puesto a la hora actual, y result vacío. Después de que el cobro se crea y corre el segundo nodo Postgres, esa misma fila cambia a status = 'done', con result conteniendo el id del cobro y updated_at actualizado. Un solo asiento, dos escrituras, la historia completa de esa ejecución legible de un vistazo.

Y aquí conviene ser honesto sobre lo que este ejemplo todavía no resuelve: por sí solo, el ledger registra pero no deduplica. Si ORD-2041 llega dos veces, la restricción UNIQUE en idempotency_key hará que el segundo INSERT falle —lo cual es bueno, es la señal—, pero un INSERT que falla detiene el nodo con un error, y todavía no decidiste qué hacer con ese error para evitar el efecto. Convertir "el insert falló" en "descarta el pedido y no cobres" es precisamente el trabajo de la tienda de deduplicación y el patrón ON CONFLICT de la lección 5. El ledger es la mitad que registra; la lección 5 es la mitad que decide. Por eso las dos tablas se complementan.

Cómo se lee el ledger: la fuente única de verdad

El valor del ledger no está solo en escribirlo, sino en poder preguntarle. Una vez que existe, responder "¿qué pasó con este pedido?" deja de ser una arqueología por el historial de n8n y se vuelve una consulta:

-- ¿Se procesó este pedido, y cómo terminó?
SELECT status, result, created_at, updated_at
FROM run_ledger
WHERE order_id = 'ORD-2041';

Con esa sola consulta —que en n8n corre un nodo Postgres en operación Select o Execute Query— respondes lo que antes exigía abrir ejecuciones a mano. Y aparecen preguntas de operación que solo un ledger permite:

-- ¿Qué ejecuciones quedaron colgadas en 'pending' hace más de 15 minutos?
-- (Candidatas a haberse caído a media ejecución: material del módulo 6.)
SELECT order_id, idempotency_key, created_at
FROM run_ledger
WHERE status = 'pending'
  AND created_at < now() - INTERVAL '15 minutes';

Esa segunda consulta es la que separa una bitácora de un ledger de verdad. Una bitácora te dice qué pasó cuando lo lees. Un ledger te deja interrogar el estado del sistema: encontrar lo que se quedó a medias, lo que falló, lo que tardó de más. Es la materia prima de las alertas y la recuperación del módulo 6, y solo la tienes porque decidiste, desde el diseño, guardar el estado y los tiempos.

Cuando decimos que el ledger es la fuente única de verdad, esto es lo que significa: si dos sistemas discrepan sobre si ORD-2041 se procesó —el CRM dice una cosa, el historial de n8n sugiere otra—, el ledger es el árbitro. No porque sea mágico, sino porque lo diseñaste para que cada ejecución dejara ahí su rastro, en orden, antes y después de actuar. La verdad no vive en la memoria de nadie; vive en el libro.

Errores comunes

Escribir el asiento solo al final (conceptual). Qué pasa: por simplicidad, alguien registra en el ledger una sola vez, después de crear el cobro, en estado done. Por qué pasa: parece redundante escribir dos veces, y "anotar cuando terminó" suena natural. Cómo detectarlo: si tu ledger no tiene filas en pending, nunca, es esto. Cómo corregirlo: escribe pending antes del efecto. Si la ejecución se cae entre el efecto y el registro, sin el asiento previo tendrías un cobro fantasma sin rastro; con él, tienes al menos un pending que grita "revisa esto". Un efecto sin asiento es invisible; un asiento sin desenlace es investigable.

Guardar la verdad del sistema en la misma columna que cambia (conceptual). Qué pasa: alguien reutiliza idempotency_key para meter información variable, o cambia el order_id de un asiento existente. Por qué pasa: se ve como "actualizar el registro". Cómo detectarlo: si tus UPDATE tocan columnas que identifican el asiento (la clave, el pedido), es esto. Cómo corregirlo: la clave y el pedido se escriben una vez y no se tocan; lo que cambia con el ciclo de vida es status, result y updated_at. Un ledger cuya identidad de fila muta deja de ser confiable como fuente de verdad.

Meter en columnas separadas todo lo que podría ir en result (práctico). Qué pasa: se crea una columna para charge_id, otra para error_message, otra para retry_count, y la tabla crece con cada dato nuevo que se quiere recordar. Por qué pasa: viene el instinto de "una columna por dato". Cómo detectarlo: si cada vez que quieres guardar algo nuevo tienes que hacer ALTER TABLE, es esto. Cómo corregirlo: para datos de resultado que varían según el desenlace, result JSONB es el bolsillo flexible; reservas columnas propias solo para lo que vas a consultar y filtrar seguido (como status). Es un balance, no una regla absoluta, pero empezar con JSONB te evita rediseñar la tabla a cada rato.

Confundir el ledger con la tienda de dedup (conceptual). Qué pasa: alguien intenta usar el ledger para todo, incluida la decisión rápida de "¿actúo o descarto?", y termina con consultas complicadas en el camino crítico. Por qué pasa: las dos tablas guardan claves, así que parecen la misma. Cómo detectarlo: si en el momento de decidir si cobras estás leyendo status, result y tiempos, estás usando el libro completo para una pregunta de sí/no. Cómo corregirlo: el ledger es para la historia rica (auditar, recuperar); la tienda de dedup de la lección 5 es para el sí/no atómico y veloz. Puedes tener las dos; cada una hace bien lo suyo.

Usar tipos de fecha sin zona horaria (práctico). Qué pasa: se define created_at como TIMESTAMP a secas, y meses después nadie sabe si las horas son del servidor, de UTC o de México. Por qué pasa: TIMESTAMP es más corto de escribir y "parece" suficiente. Cómo detectarlo: si tienes que preguntarte "¿en qué zona está esta hora?", ya te pasó. Cómo corregirlo: usa TIMESTAMPTZ, que guarda el instante con su zona. En un sistema donde los tiempos importan para detectar lo que se colgó, la ambigüedad de zona horaria es deuda que se paga en confusión.

Ejercicios

Ejercicio 1 — Justifica cada columna. Para cada columna del run_ledger, escribe en una frase qué pregunta te permite responder que perderías si la quitaras.

Ver solución
  • id: identifica cada asiento de forma única e interna; sin él, no tienes un identificador estable propio de la fila independiente de los datos de negocio.
  • idempotency_key: responde "¿este trabajo exacto ya está registrado?"; es la clave con la restricción de unicidad, sin ella no hay guardián contra duplicados.
  • order_id: responde "¿qué pasó con el pedido ORD-2041?" de forma legible; sin ella tendrías que buscar por la clave, que suele ser un hash ilegible.
  • status: responde "¿esto terminó, falló o quedó a medias?"; sin ella no distingues una ejecución exitosa de una colgada.
  • result: responde "¿qué produjo esta ejecución?" (id del cobro, error); sin ella el asiento dice que algo pasó pero no qué.
  • created_at: responde "¿cuándo empezó?"; base para detectar lo que lleva mucho colgado.
  • updated_at: responde "¿cuándo cambió por última vez?"; con created_at, te da la duración y detecta lo estancado.

Por qué funciona: diseñar una tabla es, exactamente, elegir qué preguntas quieres poder responder. Este ejercicio hace explícito ese vínculo columna-pregunta, que es el que deberías usar cuando adaptes el ledger a tu propio sistema.

Ejercicio 2 — Ordena las escrituras. Te dan estos cuatro pasos de order-triage en desorden. Ponlos en el orden correcto y explica por qué ese orden protege al sistema.

(A) UPDATE run_ledger SET status = 'done', result = {...} WHERE idempotency_key = ... (B) HTTP Request: crear el cobro en el CRM (C) INSERT INTO run_ledger (..., status) VALUES (..., 'pending') (D) Calcular la idempotency_key del pedido

Ver solución

El orden correcto es D → C → B → A.

  1. (D) Calcular la clave. Necesitas la idempotency_key antes de poder registrar nada, porque es la columna que identifica el asiento.
  2. (C) Insertar en pending. Registras la intención antes de actuar. A partir de aquí, existe un asiento que menciona este trabajo.
  3. (B) Crear el cobro. El efecto ocurre después de que ya hay un asiento que lo respalda.
  4. (A) Actualizar a done. Cierras el asiento con el desenlace.

Por qué ese orden protege: la regla es "nunca un efecto sin un asiento previo que lo mencione". Si el sistema se cayera entre (B) y (A) —cobro creado, asiento no cerrado—, quedaría una fila en pending que te avisa "aquí pasó algo que no sé cómo terminó, investígalo". En cambio, si hicieras B antes que C (efecto antes del registro) y el sistema se cayera en medio, tendrías un cobro real sin ninguna fila que lo mencione: un problema invisible. El pending previo convierte un fallo invisible en un fallo investigable.

Ejercicio 3 — Diseña una consulta de operación. Escribe (o describe en palabras si no te sientes con el SQL todavía) la consulta que responde: "¿cuántos pedidos terminaron en failed en la última hora, y cuáles son?". Después explica para qué serviría esa consulta en la práctica.

Ver solución

Una versión posible:

SELECT order_id, idempotency_key, result, updated_at
FROM run_ledger
WHERE status = 'failed'
  AND updated_at > now() - INTERVAL '1 hour'
ORDER BY updated_at DESC;

En palabras: pide las filas cuyo status sea failed y cuyo updated_at (el momento en que se marcaron como fallidas) sea de la última hora, y las ordena de la más reciente a la más antigua. La columna result te trae el error de cada una, porque ahí lo guardaste.

Para qué sirve: es la base de una alerta. Un workflow que corre esta consulta cada pocos minutos y avisa si aparecen fallos recientes te deja enterarte de un problema cuando pasa, no cuando un cliente se queja. Además, al traer result, ves el error de cada caso sin abrir nada: si diez pedidos fallaron con "CRM respondió 503", sabes que el problema es el CRM y no tus datos. Esto es exactamente el tipo de recuperación y alerta que el módulo 6 construye sobre el ledger que diseñaste aquí.

Si escribiste la consulta con pequeñas diferencias —otro orden, otras columnas—, está perfecto: lo que importa es que filtres por status = 'failed' y por una ventana de tiempo sobre updated_at.

Resumen y siguiente paso

En esta lección diseñaste el run ledger, el libro de contabilidad de tus ejecuciones. Es una tabla en Postgres que anota, asiento por asiento, qué trabajo corrió (idempotency_key, order_id), en qué estado terminó (status: pending, done, failed), qué produjo (result en JSONB) y cuándo (created_at, updated_at). La idempotency_key lleva una restricción de unicidad que convierte al ledger en guardián, y el tipo TIMESTAMPTZ guarda las horas sin ambigüedad de zona.

La idea que lo hace vivo es el patrón de las dos escrituras: registrar el asiento en pending antes del efecto, y actualizarlo a done o failed después. Ese orden garantiza que nunca haya un efecto sin un asiento que lo mencione, y convierte un fallo invisible (un cobro sin rastro) en uno investigable (un pending colgado). Por eso pending es un estado propio y no un simple "todavía no existe la fila": es la base de la recuperación del módulo 6.

Y viste el límite honesto de esta pieza: el ledger registra, pero por sí solo no deduplica. La restricción UNIQUE hace fallar el segundo INSERT del mismo pedido, pero convertir ese fallo en "descarta y no cobres" es trabajo de la tienda de deduplicación.

Antes de avanzar deberías poder: nombrar las columnas del ledger y qué pregunta responde cada una; explicar por qué se escribe pending antes del efecto; y escribir la idea de una consulta que interrogue el estado del sistema.

La lección 4 da un paso al lado antes de construir la tienda de dedup: te muestra las tres estrategias de deduplicación que existen —ventana de tiempo, clave-ya-vista y el nodo Remove Duplicates— y, sobre todo, dónde se queda corta cada una. Es la lección que te da el criterio para saber por qué la tienda de dedup de la lección 5 está construida como está, en vez de con el nodo que n8n trae de fábrica. Sin ese criterio, elegirías la estrategia equivocada para el caso equivocado.

Recursos

  • Postgres node — n8n Docs — las operaciones que vas a usar para escribir y leer el ledger: Insert (la Escritura 1), Update (la Escritura 2) y Select / Execute Query (las consultas de operación).
  • Code node — n8n Docs — el nodo donde calculas la idempotency_key con crypto, y sus límites en n8n 2.0 (sin HTTP, sin acceso al sistema de archivos).
  • PostgreSQL — CREATE TABLE — la referencia oficial de la sentencia que crea la tabla, incluidos PRIMARY KEY, UNIQUE, NOT NULL y DEFAULT. Útil para confirmar la sintaxis exacta de tu versión de Postgres.
  • PostgreSQL — JSON Types — qué es JSONB y por qué es el tipo adecuado para la columna result que guarda un resultado de forma flexible.
  • PostgreSQL — Date/Time Types — la diferencia entre TIMESTAMP y TIMESTAMPTZ, y por qué el segundo evita la ambigüedad de zona horaria en created_at y updated_at.