Aunque Creas que lo Perdiste Todo, No lo Perdiste: Reconstruir una Base de Datos desde sus Rastros
El cliente me escribió: algo andaba mal, faltaban datos. Entré a revisar y la base de datos de producción estaba vacía. El instinto dice 'lo perdimos todo'. Casi nunca es verdad. Esta es la historia de cómo reconstruimos dos días de información que el backup no tenía, usando los rastros que cada sistema deja afuera, y por qué tus registros valen más que tu base de datos.
Aunque Creas que lo Perdiste Todo, No lo Perdiste
Me enteré por el cliente. Un mensaje directo, de esos que te enderezan en la silla: algo andaba mal en su plataforma, faltaba información, los clientes no aparecían. Entré a revisar con la esperanza de que fuera un bug de la app. No lo era.
La base de datos de producción —clientes reales, cobros recurrentes reales— estaba vacía. Cualquiera que haya visto eso en producción conoce ese frío en la espalda: la sensación de que todo —el trabajo de meses, los clientes, el dinero— se evaporó en un comando.
Spoiler: no se evaporó. Y de eso va este post. No del error tonto que cualquiera comete, sino de una idea que me parece de las más importantes que aprendí haciendo software: tu base de datos casi nunca es el único lugar donde vive la verdad. Si entiendes eso, "lo perdimos todo" deja de ser una sentencia y se vuelve un problema de reconstrucción.
Lo que de Verdad Pasó
La base de una plataforma de suscripciones —clientes reales, cobros recurrentes reales— quedó vacía. No corrupta: vacía. Tablas truncadas, IDs reiniciados a 1, como recién instalada. El culpable fue un clásico: una suite de tests que, por un fallback demasiado amable, terminó apuntando a producción.
// el fallback asesino, en el helper de tests
const url = process.env.TEST_DATABASE_URL || process.env.DATABASE_URL;
// ^^^^^^^^^^^^^^^^^^^^^^^^
// sin TEST_DATABASE_URL, caia silenciosamente a la DB de produccion
El primer hook de setup hizo lo que hace cualquier suite para arrancar limpia: TRUNCATE TABLE ... RESTART IDENTITY CASCADE. Sobre prod. En segundos. (Omito nombres, IPs y montos a propósito; lo que vale aquí es el método, no el cliente.)
El Momento del Frío: No Hay Botón Mágico
Lo primero que haces es buscar la red de seguridad nativa de la base. Y aquí vino el segundo golpe, el de las decisiones que se pagan caro el día equivocado:
- Sin PITR.
archive_mode=off, sin WAL archivado. No existía un "regrésame al instante antes del TRUNCATE". - Sin réplica. Ningún espejo de dónde leer el estado previo.
- Sin auditoría.
log_statement=none: ni siquiera los logs de Postgres guardaban lo que se había escrito.
Traducción: la base no se iba a salvar a sí misma. El único activo era un dump lógico de dos días atrás. Y aquí es donde mucha gente tira la toalla y asume el hueco como pérdida total.
El Backup Tapó el Pasado, pero Dejó un Hueco
Restauramos con pg_restore --clean. Volvieron todas las tablas... al estado de hace dos días. Entre la foto del backup y el instante del wipe había una ventana de ~47 horas —el gap— llena de cobros, cancelaciones, altas y cambios de estado que no existían en ningún dump.
(Lección de bombero, gratis: el dump de seguridad que tomé justo antes de restaurar se perdió porque un montaje -v de Docker convirtió el archivo destino en un directorio vacío. Antes de saltar, verifica que tu red exista de verdad.)
Restaurar fue lo fácil. El trabajo real —y la tesis de este post— era rellenar ese hueco. Y la base ya no sabía nada de esas 47 horas. Pero otros sistemas sí.
La Idea Central: Cada Sistema que Toca tu Dato Deja un Rastro
Aquí está el cambio de mentalidad que lo salvó todo. Pensamos en la base de datos como "la fuente de la verdad", pero en un sistema real el dato no nace ni muere ahí. Pasa por una pasarela de pagos, dispara correos, escribe webhooks, queda en logs, se refleja en la contabilidad. Cada uno de esos pasos es una copia parcial de la verdad, viviendo fuera de tu alcance del desastre.
Tu TRUNCATE borró tu tabla. No borró el cobro que el banco procesó, ni el correo que el cliente recibió, ni la línea en el log del proxy. La pregunta deja de ser "¿cómo recupero la base?" y pasa a ser "¿qué sistemas tienen memoria de lo que pasó, y cómo cruzo sus rastros para re-derivar mi estado?".
En este caso, dos fuentes externas guardaban, entre las dos, casi todo lo que importaba —cada una respondiendo una pregunta distinta.
La pasarela de pagos: la verdad del dinero
Toda pasarela seria expone una API para consultar transacciones. Cada cobro y cada reembolso del gap seguía ahí, intacto, porque ese registro no es tuyo: es de ellos. El detalle que lo hizo posible fue una decisión de diseño vieja: el identificador de la orden codificaba el ID de la suscripción. Con una regex, cada movimiento volvía a su cliente.
// de cada transaccion del gap, deducir a quien pertenece
const m = order_id.match(/sub[-_](\d+)/i);
const subscriptionId = m ? Number(m[1]) : null;
// movimiento -> cliente, con monto y timestamp REALES de la pasarela
Con eso reconstruimos, uno por uno, todos los cobros y reembolsos de la ventana perdida, cuadrados contra la fuente que de verdad movió el dinero.
El proveedor de email: la verdad del estado
La pasarela dice quién pagó, pero no quién canceló. Para eso estaba la API de actividad del proveedor de correos: cada cancelación y cada aviso de pago fallido había disparado un email con su timestamp. Eso revelaba qué suscripciones murieron en el gap —algo que ningún registro de pago podía decir.
Resumido en una frase: la pasarela nos devolvió el "qué se cobró", y el email nos confirmó el "qué pasó con cada cliente". Cruzando ambos, reconstruimos casi toda la parte que le importa al cliente y a la contabilidad.
Reconstruir es Deducir con Evidencia, no Adivinar
Con las dos fuentes cruzadas, el estado de cada suscripción dejó de ser una suposición y pasó a ser una deducción respaldada:
- Cobro exitoso en el gap →
Active, con larenewal_datereanclada a la fecha real del cobro. - Cobro declinado o sin movimiento →
Past Due. - Email de cancelación en el gap →
Canceled, sin importar lo que dijera el pago.
El orden importó: primero reactivar a quienes pagaron, luego usar la evidencia de correos para bajar a Canceled a quienes se dieron de baja —validando que ninguna se reactivara por error.
Y una regla innegociable: nada destructivo, nada a ciegas. Los scripts leían las fuentes externas y generaban SQL, no lo aplicaban. Cada fase escupía un .sql idempotente que yo podía leer, diff-ear y aprobar antes de tocar la base.
-- generado por los scripts de recuperacion: revisable e idempotente
UPDATE subscriptions
SET status = 'Active', renewal_date = '2026-06-13'
WHERE id = $SUB
AND status <> 'Canceled'; -- jamas pisar una cancelacion confirmada
Idempotente significa que podía correrlo dos veces sin romper nada. Cuando reconstruyes producción a mano, esa propiedad es tu cinturón de seguridad.
Cerrar la Puerta para que no Vuelva a Pasar
Recuperar sin prevenir es solo posponer el próximo susto. El mismo día maté el fallback que lo permitió:
// si no hay TEST_DATABASE_URL, truena. Sin fallback silencioso.
function resolveTestDatabaseUrl(): string {
const url = process.env.TEST_DATABASE_URL;
if (!url) throw new Error('TEST_DATABASE_URL es obligatoria');
return url;
}
// guard antes de cualquier TRUNCATE/DROP/CREATE en pruebas
assert(process.env.NODE_ENV === 'development', 'no es entorno de test');
assert(testDbUrl !== appDbUrl, 'la DB de test no puede ser la de la app');
Más una base de pruebas efímera en su propio docker-compose, para que el entorno de tests ni siquiera comparta red con producción.
Lo que de Verdad me Llevo
- Casi nada está realmente perdido. El instinto de pánico dice "se borró todo". La realidad es que el dato dejó huellas en cada sistema que tocó. Antes de aceptar la pérdida, haz el inventario de quién más tiene memoria.
- Los registros valen más que la base. Pasarela, emails, webhooks, logs, contabilidad: trátalos como fuentes autoritativas, no como ruido. Y diseña tus identificadores (ese
order_idcon el sub adentro) para poder mapear de vuelta el día que los necesites. - Reconstruye generando SQL revisable. Solo lectura sobre las fuentes, salida idempotente, diff antes de aplicar. Lento es rápido.
- Un fallback silencioso es una bomba.
A || Bdonde B es producción no es conveniente: es el incidente esperando fecha. Que falte la config debe tronar, no degradar a algo peligroso. - Lo único de verdad irrecuperable es lo que vive en un solo lugar. Recuperamos el dinero y el estado de cada cliente al 100%. Lo único que no volvió fueron las notas internas de soporte del gap —porque esas sí existían únicamente en la base. Esa es la moraleja final: lo que solo está en un lugar, no existe.
Cuando le mostré al cliente cuánto se había recuperado, el alivio fue palpable. Y yo me quedé con la certeza de siempre: el peor momento de un incidente es el primero, cuando crees que perdiste todo. Casi nunca es cierto. Solo hay que saber dónde quedaron los rastros.