Módulo 2: Idempotencia: que repetir no duplique
4. Upserts y escrituras condicionales
Descripción
Al terminar esta lección vas a poder convertir una escritura que duplica —un INSERT a ciegas que crea una fila nueva cada vez— en una escritura que no duplica, usando un upsert: "inserta si no existe, actualiza si existe, pero nunca dupliques". Vas a entender la pieza sin la cual el upsert no funciona —la restricción de unicidad sobre la columna clave—, vas a escribir un upsert en SQL con ON CONFLICT, y vas a saber cómo lo expresan el nodo de base de datos y el de hoja de cálculo dentro de n8n.
Esto importa porque el upsert es, de lejos, la herramienta de idempotencia que más vas a usar en tu vida real con workflows. La cabecera de la lección 5 depende de que la API coopere; el upsert no depende de nadie: es tu base de datos garantizándote que un order_id no puede existir dos veces. Cada vez que un workflow escribe en una tabla, una hoja o un CRM que tú controlas, el upsert es la respuesta al duplicado. Es la que arregla el INSERT que hoy le mete a Cumbre una segunda fila por cada reintento del webhook.
Conexión con el módulo: la lección 3 te dio la clave —el order_id natural o el hash sintético—. Esta lección la usa: la clave es la columna por la que el upsert decide "esto ya existe". Es la primera de las dos formas de aplicar una clave a un efecto; la lección 5 es la otra (la cabecera, para APIs de terceros). La lección 6 va a mostrar por qué el upsert es superior a la solución ingenua de "verificar y luego insertar" —porque el upsert es atómico y la otra no—. Y el proyecto de la lección 8 convierte, entre otras cosas, el INSERT del CRM en el upsert que aprendes aquí.
El problema: el INSERT a ciegas
Volvamos al efecto del HTTP Request de Cumbre que escribe en el CRM. Supongamos que el CRM guarda los pedidos en una tabla orders, y que el workflow los inserta así:
INSERT INTO orders (order_id, customer_id, amount, status)
VALUES ('ORD-2041', 'CUST-118', 1780, 'pending');
Este INSERT es un "agregar al carrito" de manual: cada vez que corre, crea una fila nueva. La lección 2 ya lo clasificó como no idempotente, y ahora vemos el daño concreto. Cuando el webhook llega dos veces con ORD-2041, este INSERT corre dos veces, y la tabla orders termina con dos filas para el mismo pedido:
| id | order_id | customer_id | amount | status |
|---|---|---|---|---|
| 1 | ORD-2041 | CUST-118 | 1780 | pending |
| 2 | ORD-2041 | CUST-118 | 1780 | pending |
Ahora cualquier reporte que cuente pedidos da un número inflado, cualquier proceso que recorra la tabla procesa ORD-2041 dos veces, y si otro workflow lee "el pedido ORD-2041" no sabe cuál de las dos filas es la buena. Un solo duplicado ensucia todo lo que toca esa tabla aguas abajo.
Lo que queremos es que la segunda escritura reconozca "este order_id ya está" y no cree una fila nueva. Queremos convertir la tabla en un conjunto por order_id —¿recuerdas el conjunto de la lección 2, que ignora los repetidos?—. Esa conversión se llama upsert.
Qué es un upsert
Upsert es la fusión de dos palabras: update + insert. Describe una sola operación que decide sola cuál de las dos hacer:
Si la fila no existe, la inserta. Si ya existe, la actualiza (o no hace nada). Nunca crea un duplicado.
Piénsalo con la agenda de contactos de tu teléfono. Cuando guardas a "Juan" con su número, el teléfono no crea un segundo "Juan" cada vez que le actualizas el número: busca si ya existe un contacto llamado Juan y, si lo encuentra, le actualiza los datos en lugar de duplicarlo. Tu agenda no tiene cinco "Juan"; tiene uno, con la información más reciente. Eso es un upsert: la operación es "que exista Juan con estos datos", no "agrega un Juan". Fijar un estado, no acumular —exactamente la heurística idempotente de la lección 2—.
Para que esto funcione, la operación necesita saber por cuál campo decidir si algo "ya existe". En la agenda es el nombre. En Cumbre es el order_id. Ese campo es la clave de la lección 3 puesta a trabajar: el upsert la usa para preguntar "¿ya hay una fila con este order_id?".
La pieza invisible sin la cual nada funciona: la restricción de unicidad
Aquí está el detalle que más gente pasa por alto, y por eso su upsert "no duplica en la prueba pero duplica en producción". Presta atención, porque es la mitad de la lección.
Para que la base de datos pueda decidir "esta fila ya existe", tiene que haber una regla que le diga qué columna no puede repetirse. Esa regla se llama restricción de unicidad (unique constraint) o índice único, y se define sobre la tabla, no en tu workflow. En Cumbre, sobre la columna order_id:
-- Esto se hace UNA VEZ, al crear o preparar la tabla.
-- Le dice a la base de datos: "no puede haber dos filas con el mismo order_id".
ALTER TABLE orders ADD CONSTRAINT orders_order_id_unique UNIQUE (order_id);
Sin esta restricción, la base de datos no tiene forma de saber qué significa "ya existe", y el upsert no tiene contra qué chocar. Peor: en muchos casos, un upsert sobre una columna sin restricción de unicidad se degrada silenciosamente a un INSERT normal, y vuelves a duplicar sin ningún error que te avise.
La analogía: el upsert es la instrucción "no dupliques a Juan", pero la restricción de unicidad es la que define qué cuenta como "el mismo Juan" —¿el nombre?, ¿el teléfono?, ¿el correo?—. Sin decidir eso primero, la instrucción no tiene sentido. La restricción de unicidad es esa decisión, hecha una vez, a nivel de la tabla.
Regla dura que te ahorra horas: antes de escribir cualquier upsert, confirma que existe una restricción de unicidad sobre la columna clave. Si no existe, créala. Es el paso cero, y saltárselo es la causa número uno de "mi upsert no funciona".
El upsert en SQL: ON CONFLICT
Veamos el upsert real. En PostgreSQL —la base de datos que trae el Starter Kit v2 que usas en los laboratorios— el upsert se escribe con la cláusula ON CONFLICT:
INSERT INTO orders (order_id, customer_id, amount, status)
VALUES ('ORD-2041', 'CUST-118', 1780, 'pending')
ON CONFLICT (order_id) DO NOTHING;
Léelo en voz alta, porque se lee casi como español: "inserta esta fila; y si hay un conflicto en order_id —es decir, si ya existe una fila con ese order_id— no hagas nada". La primera vez que llega ORD-2041, no hay conflicto: inserta. La segunda vez, choca con la restricción de unicidad sobre order_id, entra el DO NOTHING, y no se crea una segunda fila. La tabla queda con una sola fila, sin importar cuántas veces corra.
Hay dos sabores de ON CONFLICT, y la diferencia importa:
DO NOTHING — si ya existe, ignora la escritura por completo. Se queda la fila que ya estaba. Es lo que quieres cuando la primera escritura es la verdad y las repeticiones no aportan nada nuevo.
DO UPDATE — si ya existe, actualiza la fila con los datos nuevos. Se usa cuando la repetición sí puede traer información más fresca que quieres conservar:
INSERT INTO orders (order_id, customer_id, amount, status)
VALUES ('ORD-2041', 'CUST-118', 1780, 'pending')
ON CONFLICT (order_id) DO UPDATE
SET amount = EXCLUDED.amount,
status = EXCLUDED.status;
EXCLUDED es una palabra especial de PostgreSQL que significa "los valores que intentabas insertar". Así que esto dice: "si ORD-2041 ya existe, actualízale el amount y el status con los que traía esta llegada". Fíjate en que sigue siendo idempotente: actualizar ORD-2041 al mismo amount y status diez veces lo deja igual que hacerlo una. Fijar un valor, no acumular.
¿Cuál de los dos elegir? Si las dos llegadas del mismo evento traen datos idénticos —el caso típico de un reintento—, DO NOTHING y DO UPDATE dan el mismo resultado y DO NOTHING es más simple. Si la segunda llegada pudiera traer una corrección (un status más avanzado, un dato que faltaba), DO UPDATE conserva lo más reciente. Para el reintento puro de Cumbre, DO NOTHING basta y sobra.
Una nota para quien use MySQL en vez de PostgreSQL: la idea es idéntica, la sintaxis cambia. MySQL lo escribe como INSERT ... ON DUPLICATE KEY UPDATE ..., y también depende de que exista una clave única sobre la columna. El concepto viaja entre bases de datos; verifica la sintaxis exacta de la tuya en su documentación.
Ejemplo trabajado: el upsert de order-triage en n8n
Vamos a hacerlo en n8n, sobre la tabla orders de Cumbre, y a probar que re-ejecutar deja una sola fila.
Paso 0 — La restricción de unicidad (una sola vez). Antes de nada, asegúrate de que la tabla tenga la restricción sobre order_id. En un nodo Postgres en modo de ejecutar una consulta, o directamente en tu cliente de base de datos, corre una vez:
ALTER TABLE orders ADD CONSTRAINT orders_order_id_unique UNIQUE (order_id);
Qué esperar: si la tabla no tenía la restricción, se crea sin ruido. Si ya la tenía, obtendrás un error de "ya existe" que puedes ignorar. Si al correrla te dice que hay valores duplicados, ¡buena noticia disfrazada de mala!: significa que ya tienes duplicados de antes, y tienes que limpiarlos antes de poder imponer la unicidad.
Paso 1 — El nodo de base de datos. En n8n, el nodo Postgres ofrece operaciones distintas. Una de ellas es específicamente un upsert —en la interfaz suele aparecer como una operación de tipo Upsert o "Insert or Update"; verifica la etiqueta exacta en tu versión, porque el nombre ha variado—. Esa operación te pide dos cosas: la columna por la que detectar el conflicto (aquí, order_id) y los campos a escribir. Por debajo, hace exactamente el ON CONFLICT que viste arriba.
Si prefieres control total y no depender de la etiqueta del nodo, usa la operación de ejecutar una consulta (Execute Query) y escribe el INSERT ... ON CONFLICT a mano, pasando los valores del item con parámetros. Es más explícito y funciona igual en cualquier versión:
-- En un nodo Postgres, operación "Execute Query".
-- Los valores vienen del item; usa la parametrización del nodo, no concatenación de texto.
INSERT INTO orders (order_id, customer_id, amount, status)
VALUES ($1, $2, $3, 'pending')
ON CONFLICT (order_id) DO NOTHING;
Los $1, $2, $3 son marcadores de parámetro que el nodo llena con los campos del pedido (order_id, customer_id, amount). Pasar los valores como parámetros, en vez de pegarlos dentro del texto de la consulta, es la forma correcta y segura de hacerlo; la guía de APIs del ecosistema explica por qué.
Paso 2 — Ejecutar una vez. Dispara el workflow con el pedido ORD-2041. Qué esperar: la tabla orders ahora tiene una fila con ORD-2041. Consúltala con un SELECT * FROM orders WHERE order_id = 'ORD-2041' y verás exactamente una fila.
Paso 3 — Ejecutar otra vez, a propósito. Vuelve a disparar el mismo pedido ORD-2041, simulando el reintento del webhook. Qué esperar: el nodo corre sin error —esto es importante, no falla— y al volver a consultar SELECT * FROM orders WHERE order_id = 'ORD-2041' sigue habiendo exactamente una fila. La segunda ejecución chocó con la restricción de unicidad, el DO NOTHING la absorbió, y no duplicó.
Esa es la prueba de idempotencia de la lección 8 en miniatura: no "corrió sin error" (que también), sino "corrí dos veces y hay exactamente una fila". Acabas de convertir un efecto no idempotente en uno idempotente sin depender de nadie más que de tu propia base de datos.
El upsert en una hoja de cálculo: Google Sheets
No todos los workflows escriben en una base de datos de verdad. Muchos equipos pequeños —como Cumbre bien podría— guardan datos en una hoja de cálculo de Google Sheets. La buena noticia: la misma idea de upsert existe ahí.
El nodo Google Sheets de n8n ofrece una operación que hace precisamente esto: en lugar de solo "agregar una fila" (que duplicaría), tiene una operación de tipo Append or Update —"agregar o actualizar"— que verifica los valores de fila(s) existentes y decide. En la interfaz, esa operación te pide una columna por la que hacer coincidencia (column to match on): eliges order_id, y el nodo busca si ya hay una fila con ese order_id. Si la encuentra, la actualiza; si no, agrega una nueva. Verifica la etiqueta exacta de la operación y del campo en tu versión, porque n8n ajusta esos nombres de vez en cuando.
La diferencia importante frente a la base de datos: una hoja de cálculo no tiene una restricción de unicidad real. Nadie impide, a nivel del archivo, que existan dos filas con el mismo order_id; la unicidad la garantiza el nodo al buscar-y-decidir, no la hoja. Eso tiene dos consecuencias que conviene tener claras. Primera, si por cualquier vía se colaron dos filas con el mismo order_id (por ejemplo, alguien las pegó a mano, o un workflow viejo usó "agregar" en vez de "agregar o actualizar"), la operación de coincidencia se puede confundir sobre cuál actualizar. Segunda, y más delicada, esta estrategia tiene una debilidad de concurrencia que la base de datos no tiene —y que es justo el tema de la lección 6—: si dos ejecuciones buscan "¿existe ORD-2041?" al mismo tiempo, las dos pueden ver que no existe y las dos agregar una fila. La base de datos, con su restricción de unicidad, cierra esa ventana; la hoja de cálculo, no del todo. Por eso, cuando la corrección importa de verdad —dinero, inventario—, una base de datos con restricción de unicidad es más robusta que una hoja.
Dicho esto, para muchos casos reales la hoja con "agregar o actualizar" es perfectamente suficiente, y es infinitamente mejor que "agregar a ciegas". Úsala sabiendo su límite.
Una regla práctica para decidir entre hoja y base de datos: si el dato que escribes es dinero, inventario o cualquier cosa cuyo duplicado le cueste a alguien —un cobro, una reserva de stock, una comisión—, ve por una base de datos con restricción de unicidad, que cierra la ventana de concurrencia de raíz. Si el dato es informativo y un duplicado ocasional se puede limpiar sin consecuencias —una fila de registro para consulta interna—, la hoja con "agregar o actualizar" es una elección pragmática y suficiente. No es que una sea "buena" y la otra "mala"; es que la robustez que necesitas depende de lo caro que sea equivocarse, y elegir con ese criterio es parte del oficio.
Por qué el upsert es UNA sola operación (y por qué eso importa)
Hay una virtud del upsert que se ve pequeña y es enorme, y que la lección 6 va a convertir en el corazón de su argumento. Vale la pena sembrarla aquí.
El upsert es una sola instrucción atómica. "Atómica" quiere decir que la base de datos la ejecuta como un bloque indivisible: la decisión "¿ya existe?" y la acción "inserta o ignora" ocurren pegadas, sin que nada pueda meterse en medio. Nadie puede colarse entre el "¿existe?" y el "insertar" para cambiar la respuesta.
Compara esto con la alternativa ingenua que a todos se nos ocurre primero: "primero hago un SELECT para ver si ORD-2041 ya existe, y si no existe, hago un INSERT". Son dos operaciones separadas, con un hueco entre ellas. Y en ese hueco cabe otra ejecución. Imagina dos reintentos del webhook corriendo casi al mismo tiempo:
Ejecución A: SELECT ORD-2041 → no existe
Ejecución B: SELECT ORD-2041 → no existe (¡todavía no existe, A no ha insertado!)
Ejecución A: INSERT ORD-2041 → crea la fila
Ejecución B: INSERT ORD-2041 → crea OTRA fila
Las dos preguntaron "¿existe?", las dos vieron "no", y las dos insertaron. Dos filas. La verificación no sirvió de nada porque entre preguntar y actuar pasó tiempo, y en ese tiempo el mundo cambió.
El upsert no tiene ese hueco. La restricción de unicidad + el ON CONFLICT hacen que la decisión y la acción sean la misma operación indivisible: cuando B intenta insertar, la base de datos ya tiene la fila de A y el DO NOTHING la absorbe, sin importar cuán juntas corrieron. Por eso el upsert es robusto donde el "verifica y luego inserta" es frágil.
No te preocupes por dominar esto todavía —la lección 6 le dedica toda su atención, con el nombre técnico y todo—. Por ahora quédate con una consigna: prefiere siempre una sola operación atómica (el upsert) sobre dos operaciones separadas (verificar y luego crear). Es la diferencia entre una idempotencia que aguanta la concurrencia y una que se rompe en cuanto dos cosas corren a la vez.
Qué columna usar como clave del upsert
Un cierre práctico antes de los errores comunes: ¿por cuál columna haces el upsert, el order_id o el idempotency_key de la lección 3?
La respuesta es la misma lógica de la lección 3. Si el evento tiene una clave natural buena —el order_id estable de los pedidos web— úsala como columna del conflicto: es legible, y cuando depures un duplicado en producción vas a agradecer buscar por ORD-2041 en vez de por un hash. Si el evento no tiene clave natural —los pedidos de WhatsApp sin order_id— guarda el idempotency_key sintético en una columna de la tabla y pon la restricción de unicidad sobre esa columna. El upsert entonces choca por idempotency_key.
En muchos diseños reales conviene tener las dos cosas: la columna de negocio (order_id cuando existe) y una columna idempotency_key que siempre está poblada —con la clave natural cuando la hay, o con el hash cuando no—, y poner la unicidad sobre idempotency_key. Así tu upsert siempre tiene una única columna contra la cual chocar, sin importar el canal. Es un patrón que el módulo 4 formaliza cuando construyas el ledger de deduplicación; por ahora quédate con la idea de que la columna del conflicto es tu clave de idempotencia, sea natural o sintética.
Errores comunes
Hacer el upsert sin la restricción de unicidad (práctico). Qué pasa: alguien configura el nodo con la operación de upsert, elige order_id como columna de coincidencia, lo prueba, y aun así aparecen filas duplicadas. Por qué pasa: la tabla no tiene una restricción de unicidad sobre order_id, así que la base de datos no tiene contra qué "chocar". Dependiendo del nodo y la versión, el upsert puede degradarse a un INSERT normal, o el nodo puede hacer un "busca y decide" en dos pasos que sufre la condición de carrera de la lección 6. En cualquier caso, duplica. Cómo detectarlo: consulta las restricciones de la tabla —en PostgreSQL, revisa los índices y constraints de orders— y confirma que hay un UNIQUE sobre la columna clave. Si no está, ese es el problema, no tu configuración del nodo. Cómo corregirlo: crea la restricción una vez con ALTER TABLE ... ADD CONSTRAINT ... UNIQUE (order_id). Si al crearla falla por valores duplicados existentes, limpia primero esos duplicados y luego impón la unicidad. El paso cero de todo upsert es garantizar la restricción.
Elegir la columna de conflicto equivocada (práctico). Qué pasa: se pone la restricción y el upsert sobre una columna que no identifica al evento de forma única —por ejemplo customer_id en vez de order_id—. El resultado es peor que duplicar: ahora dos pedidos distintos del mismo cliente colisionan, y el segundo pedido sobrescribe o descarta al primero. Perdiste una venta. Por qué pasa: se confunde "una columna que se repite poco" con "la columna que identifica el evento". customer_id se repite legítimamente —un cliente hace muchos pedidos—; no es una clave de idempotencia. Cómo detectarlo: pregúntate si dos eventos distintos podrían tener el mismo valor en esa columna. Si sí (dos pedidos del mismo cliente comparten customer_id), la columna está mal. Cómo corregirlo: la columna del conflicto debe ser la clave de idempotencia de la lección 3 —el order_id o el idempotency_key—, es decir, algo que sea idéntico para el mismo evento y distinto para eventos distintos. Ni más grueso ni más fino.
Usar DO UPDATE cuando la segunda llegada trae datos peores (práctico). Qué pasa: se elige ON CONFLICT DO UPDATE para "conservar lo más reciente", pero resulta que la segunda llegada del reintento a veces trae un dato incompleto que sobrescribe uno bueno de la primera —por ejemplo, un status que retrocede de paid a pending—. Por qué pasa: DO UPDATE confía en que la llegada más reciente es la mejor, y en un reintento eso no siempre es cierto; el reintento puede ser una copia vieja del evento. Cómo detectarlo: revisa si el campo que actualizas puede "empeorar" entre llegadas; los campos de estado que solo deben avanzar son los sospechosos. Cómo corregirlo: para reintentos puros de datos idénticos, prefiere DO NOTHING —la primera escritura es la verdad y punto—. Si necesitas DO UPDATE pero el estado solo debe avanzar, agrega una condición que impida el retroceso (en PostgreSQL, una cláusula WHERE en el DO UPDATE que solo actualice si el nuevo estado es "mayor"). El punto es no sobrescribir a ciegas.
Ejercicios
Ejercicio 1 — Lee el upsert. Explica en tus palabras qué hace cada una de estas dos consultas cuando ORD-2041 ya existe en la tabla, y en qué se diferencian:
-- Consulta A
INSERT INTO orders (order_id, amount, status)
VALUES ('ORD-2041', 1780, 'pending')
ON CONFLICT (order_id) DO NOTHING;
-- Consulta B
INSERT INTO orders (order_id, amount, status)
VALUES ('ORD-2041', 1780, 'paid')
ON CONFLICT (order_id) DO UPDATE
SET status = EXCLUDED.status;
Ver solución
Consulta A (DO NOTHING): intenta insertar ORD-2041; como ya existe, choca con la restricción de unicidad y no hace nada. La fila que ya estaba se queda intacta, con el status que tuviera. No se crea una segunda fila. Es idempotente: córrela mil veces y la tabla no cambia después de la primera.
Consulta B (DO UPDATE): intenta insertar ORD-2041; como ya existe, en lugar de ignorar, actualiza la fila existente poniéndole status = 'paid' (el valor que traía, vía EXCLUDED.status). La fila sigue siendo una sola, pero ahora su status es 'paid'. También es idempotente: actualizar a 'paid' diez veces lo deja en 'paid'.
La diferencia: A ignora la repetición y conserva lo viejo; B usa la repetición para actualizar un campo. Las dos evitan el duplicado (una sola fila); difieren en si la segunda llegada puede cambiar datos. Para un reintento con datos idénticos, dan el mismo resultado; A es más simple.
Por qué funciona: las dos convierten "agregar una fila" (no idempotente) en "que exista la fila con estos datos" (idempotente). Es la heurística fija-vs-acumula de la lección 2, ahora en SQL.
Ejercicio 2 — Diagnostica el upsert que duplica. Un compañero jura que configuró el upsert bien —eligió la operación de upsert, puso order_id como columna de coincidencia— pero la tabla sigue llenándose de ORD-2041 duplicados en cada reintento. El nodo no arroja ningún error. ¿Cuál es la causa más probable y cómo la confirmas?
Ver solución
La causa más probable es que la tabla orders no tiene una restricción de unicidad sobre order_id. Sin ella, la base de datos no tiene contra qué "chocar", así que el mecanismo de conflicto nunca se dispara: el upsert se comporta como un INSERT normal (o hace un "buscar y decidir" que sufre condiciones de carrera), y por eso duplica sin dar error. El hecho de que "no arroje ningún error" es la pista: un upsert que degrada a insert no falla, simplemente no protege.
Cómo confirmarlo: consulta las restricciones e índices de la tabla. En PostgreSQL, revisa los constraints de orders (por ejemplo con \d orders en el cliente psql, o consultando el catálogo del sistema) y busca un UNIQUE sobre order_id. Si no aparece, ese es el problema.
Cómo corregirlo: crear la restricción una vez —ALTER TABLE orders ADD CONSTRAINT orders_order_id_unique UNIQUE (order_id)—. Si al crearla falla porque ya hay duplicados, primero hay que limpiarlos (conservar una fila por order_id y borrar el resto) y después imponer la unicidad. A partir de ahí, el upsert que ya estaba configurado empieza a funcionar sin tocar el nodo.
Por qué funciona: separaste "configuré el nodo" de "la tabla puede hacer cumplir la unicidad". El upsert es una colaboración entre los dos: el nodo pide "no dupliques por order_id" y la restricción es la que de verdad lo impide. Falta la mitad de la colaboración.
Ejercicio 3 — Elige la columna del conflicto para los tres canales. Cumbre guarda todos los pedidos en una tabla orders, sin importar el canal. Diseña la estrategia de columna de conflicto que funcione para los tres, usando lo que aprendiste en las lecciones 3 y 4:
web: traeorder_idestable.whatsapp: sinorder_id, pero le calculas unidempotency_keysintético.rep_csv: sinorder_id, también conidempotency_keysintético.
Ver solución
La estrategia robusta: una columna idempotency_key que siempre esté poblada, con la restricción de unicidad sobre ella, y el upsert que choca por esa columna.
Para web, el idempotency_key se llena con la clave natural: el propio order_id (ORD-2041). Para whatsapp y rep_csv, se llena con el hash sintético que calculaste en el nodo Code. Así, sin importar el canal, cada pedido llega a la tabla con un idempotency_key único y estable, y el upsert siempre tiene una sola columna contra la cual chocar:
ALTER TABLE orders ADD CONSTRAINT orders_idem_unique UNIQUE (idempotency_key);
INSERT INTO orders (idempotency_key, order_id, customer_id, amount, status)
VALUES ($1, $2, $3, $4, 'pending')
ON CONFLICT (idempotency_key) DO NOTHING;
(Puedes conservar order_id como columna de negocio cuando exista, pero la unicidad va sobre idempotency_key.)
La alternativa de poner la unicidad sobre order_id no sirve para los tres canales, porque whatsapp y rep_csv no tienen order_id —quedaría nulo, y una columna llena de nulos no distingue eventos—. Unificar todo bajo idempotency_key resuelve el problema de raíz.
Por qué funciona: uniste las dos lecciones. La 3 te dio una clave siempre disponible (natural o sintética); la 4 la usa como la única columna de conflicto. Un solo mecanismo para todos los canales, en vez de tres reglas distintas. Este es exactamente el patrón que el módulo 4 va a escalar a un ledger de deduplicación.
Resumen y siguiente paso
En esta lección convertiste el INSERT a ciegas —que le mete a Cumbre una fila nueva por cada reintento— en un upsert: "inserta si no existe, actualiza o ignora si existe, nunca dupliques". Lo entendiste con la agenda de contactos que no crea dos "Juan", viste su forma en SQL con ON CONFLICT (order_id) DO NOTHING y su variante DO UPDATE, y aprendiste la pieza invisible sin la cual nada funciona: la restricción de unicidad sobre la columna clave, que es la que de verdad impide el duplicado y cuya ausencia es la causa número uno de "mi upsert no protege". Lo llevaste a n8n —la operación de upsert del nodo Postgres o un Execute Query con ON CONFLICT, y la operación "agregar o actualizar" de Google Sheets con su columna de coincidencia— y probaste la idempotencia de verdad: correr dos veces y encontrar exactamente una fila. Y cerraste eligiendo la columna del conflicto: el order_id natural cuando existe, el idempotency_key sintético cuando no, unificados en una sola columna que siempre está poblada.
Antes de avanzar a la lección 5 deberías poder: escribir un upsert con ON CONFLICT; explicar por qué sin la restricción de unicidad el upsert se degrada a un insert que duplica; y elegir la columna de conflicto correcta para un evento con y sin clave natural.
El upsert resuelve el duplicado cuando el efecto es tu propia base de datos, donde tú controlas la restricción de unicidad. Pero el otro efecto de Cumbre —el charge en la pasarela de pago— vive en un sistema de terceros, donde tú no puedes crear una restricción de unicidad. La lección 5 resuelve ese caso: la cabecera Idempotency-Key, la forma en que las APIs serias te dejan decir "este cobro y el anterior son el mismo" sin que tú controles su base de datos. Y para las APIs que no ofrecen esa cabecera, vas a ver el patrón de verificar-antes-de-crear... con una advertencia grande, porque ese patrón esconde la trampa que la lección 6 va a desarmar.
Recursos
- INSERT ... ON CONFLICT — PostgreSQL documentation — la referencia oficial del upsert en PostgreSQL:
DO NOTHING,DO UPDATE,EXCLUDEDy el requisito del índice único. La base de esta lección. - Postgres node — n8n Docs — las operaciones del nodo
Postgresen n8n, incluida la de upsert y la de ejecutar consultas; verifica ahí la etiqueta exacta de cada operación en tu versión. - Google Sheets node — n8n Docs — la operación "agregar o actualizar" y su columna de coincidencia, el upsert del mundo de las hojas de cálculo.
- UNIQUE constraints — PostgreSQL documentation — cómo se define la restricción de unicidad que hace posible el upsert; el paso cero que nunca debes saltarte.
- INSERT ... ON DUPLICATE KEY UPDATE — MySQL documentation — el equivalente del upsert en MySQL, por si tu base de datos no es PostgreSQL; misma idea, otra sintaxis.