1
Split de
nombre_completoDividir en nombre + apellidos sin interrupción · 3 migraciones encadenadas
Nunca renombrar una columna en un solo paso en producción. Se aplica el patrón expand-migrate-contract: añadir columna nueva → backfill → eliminar columna vieja.
20240901000001-add-nombre-apellidos.js — Paso 1/3
SEGURO
EXPAND
Sequelize
javascript
// ✅ Fase EXPAND: añadir columnas nuevas (app vieja sigue funcionando)
module.exports = {
up: async (queryInterface, Sequelize) => {
const transaction = await queryInterface.sequelize.transaction();
try {
await queryInterface.addColumn('pacientes', 'nombre', {
type: Sequelize.STRING(100),
allowNull: true, // nullable mientras convivimos con app antigua
}, { transaction });
await queryInterface.addColumn('pacientes', 'apellidos', {
type: Sequelize.STRING(150),
allowNull: true,
}, { transaction });
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
down: async (queryInterface) => {
await queryInterface.removeColumn('pacientes', 'apellidos');
await queryInterface.removeColumn('pacientes', 'nombre');
},
};
Plan de rollback
db:migrate:undoelimina ambas columnas si la app aún no las usa.- Transacción atómica: si falla el segundo addColumn, el primero se deshace automáticamente.
20240901000002-backfill-nombre-apellidos.js — Paso 2/3
DATOS
MIGRATE
Sequelize
javascript
// ✅ Fase MIGRATE: backfill por lotes de 500 (evita lock en 18k filas)
module.exports = {
up: async (queryInterface) => {
const BATCH = 500;
let offset = 0;
while (true) {
const [rows] = await queryInterface.sequelize.query(
`SELECT id, nombre_completo FROM pacientes
WHERE nombre IS NULL
ORDER BY id
LIMIT :limit OFFSET :offset`,
{ replacements: { limit: BATCH, offset } }
);
if (!rows.length) break;
for (const row of rows) {
const parts = (row.nombre_completo || '').trim().split(' ');
const nombre = parts[0] || '';
const apellidos = parts.slice(1).join(' ');
await queryInterface.sequelize.query(
`UPDATE pacientes
SET nombre = :nombre, apellidos = :apellidos
WHERE id = :id`,
{ replacements: { nombre, apellidos, id: row.id } }
);
}
offset += BATCH;
}
},
down: async (queryInterface) => {
// El backfill es reversible: los datos siguen en nombre_completo
await queryInterface.sequelize.query(
`UPDATE pacientes SET nombre = NULL, apellidos = NULL`
);
},
};
Plan de rollback
- El rollback limpia los campos nuevos; nombre_completo original sigue intacto.
- Batches de 500 evitan deadlocks y mantienen la tabla operativa.
- Idempotente: si se interrumpe, se puede relanzar sin duplicar datos.
20240901000003-drop-nombre-completo.js — Paso 3/3
DESTRUCTIVO
CONTRACT
Sequelize
javascript
// ⚠️ Ejecutar SÓLO tras confirmar que la nueva app usa nombre/apellidos
// Prerequisito: deploy v2 activo + nombre/apellidos NOT NULL correctos
module.exports = {
up: async (queryInterface, Sequelize) => {
// Primero poner NOT NULL en los campos nuevos
await queryInterface.changeColumn('pacientes', 'nombre', {
type: Sequelize.STRING(100),
allowNull: false,
});
await queryInterface.changeColumn('pacientes', 'apellidos', {
type: Sequelize.STRING(150),
allowNull: false,
defaultValue: '',
});
await queryInterface.removeColumn('pacientes', 'nombre_completo');
},
down: async (queryInterface, Sequelize) => {
// Restaurar columna original combinando datos
await queryInterface.addColumn('pacientes', 'nombre_completo', {
type: Sequelize.STRING(200),
});
await queryInterface.sequelize.query(
`UPDATE pacientes
SET nombre_completo = TRIM(nombre || ' ' || apellidos)`
);
await queryInterface.changeColumn('pacientes', 'nombre_completo', {
type: Sequelize.STRING(200),
allowNull: false,
});
},
};
Plan de rollback
- Rollback reconstruye nombre_completo concatenando nombre + apellidos.
- Ejecutar este paso solo cuando el 100% del tráfico esté en la app nueva.
2
Columna
plan_suscripcionENUM PostgreSQL con default 'free' · Sin riesgo de datos existentes
20240902000001-add-plan-suscripcion.js
SEGURO
Sequelize + ENUM
javascript
module.exports = {
up: async (queryInterface, Sequelize) => {
// 1. Crear el tipo ENUM en PostgreSQL
await queryInterface.sequelize.query(
`CREATE TYPE "enum_pacientes_plan_suscripcion"
AS ENUM ('free', 'pro', 'enterprise')`
);
// 2. Añadir la columna con default
await queryInterface.addColumn('pacientes', 'plan_suscripcion', {
type: Sequelize.ENUM('free', 'pro', 'enterprise'),
defaultValue: 'free',
allowNull: false,
});
// 3. Índice para queries por plan (frecuentes en el dashboard)
await queryInterface.addIndex('pacientes', ['plan_suscripcion'], {
name: 'idx_pacientes_plan',
});
},
down: async (queryInterface) => {
await queryInterface.removeIndex('pacientes', 'idx_pacientes_plan');
await queryInterface.removeColumn('pacientes', 'plan_suscripcion');
await queryInterface.sequelize.query(
`DROP TYPE "enum_pacientes_plan_suscripcion"`
);
},
};
Plan de rollback
- Rollback limpia columna e índice y borra el tipo ENUM en el orden correcto.
- El DEFAULT 'free' asegura que los 18k registros existentes son válidos sin UPDATE previo.
3
UUID nativo en tabla
citasMigrar de VARCHAR(36) a tipo UUID nativo PostgreSQL · 143k filas
Esta migración tiene riesgo medio: implica cambio de tipo con conversión de datos. Se usa tabla de backup y verificación antes de eliminar datos originales.
20240903000001-uuid-nativo-citas.js
RIESGO MEDIO
CHECKPOINT
Sequelize
javascript
module.exports = {
up: async (queryInterface, Sequelize) => {
// ── CHECKPOINT 1: backup de seguridad ──
await queryInterface.sequelize.query(
`CREATE TABLE citas_backup_uuid AS SELECT * FROM citas`
);
try {
// ── PASO 1: nueva columna UUID nativo ──
await queryInterface.addColumn('citas', 'id_uuid', {
type: Sequelize.UUID,
allowNull: true,
});
// ── PASO 2: conversión masiva (CAST directo en PG) ──
await queryInterface.sequelize.query(
`UPDATE citas SET id_uuid = id::uuid`
);
// ── PASO 3: verificar que todos los registros tienen id_uuid ──
const [[{ count }]] = await queryInterface.sequelize.query(
`SELECT COUNT(*) AS count FROM citas WHERE id_uuid IS NULL`
);
if (parseInt(count) > 0) {
throw new Error(`Verificación fallida: ${count} filas sin id_uuid`);
}
// ── PASO 4: hacer NOT NULL y añadir índice único ──
await queryInterface.changeColumn('citas', 'id_uuid', {
type: Sequelize.UUID,
allowNull: false,
});
await queryInterface.addIndex('citas', ['id_uuid'], {
unique: true,
name: 'idx_citas_id_uuid_unique',
using: 'HASH', // Más rápido para lookups por igualdad
});
// ── PASO 5: eliminar backup (migración exitosa) ──
await queryInterface.dropTable('citas_backup_uuid');
} catch (err) {
// ── ROLLBACK AUTOMÁTICO desde backup ──
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS citas`);
await queryInterface.sequelize.query(
`CREATE TABLE citas AS SELECT * FROM citas_backup_uuid`
);
await queryInterface.dropTable('citas_backup_uuid');
throw err;
}
},
down: async (queryInterface) => {
await queryInterface.removeIndex('citas', 'idx_citas_id_uuid_unique');
await queryInterface.removeColumn('citas', 'id_uuid');
},
};
Plan de rollback
- Si el CAST falla, el bloque catch reconstruye la tabla desde citas_backup_uuid.
- La verificación explícita (COUNT NULL) previene corrupciones silenciosas.
- El backup se elimina solo si todos los pasos pasan: checkpoint explícito.
4
Checklist de despliegue seguro
Orden de operaciones recomendado para producción 24/7
-
Pre-deploy: backup completo de la BD pg_dump nutritrack_prod > backup_$(date +%Y%m%d_%H%M).sql — guardar en S3 antes de cualquier migración.
-
Ejecutar migraciones 1a y 1b (EXPAND + MIGRATE) La app antigua sigue funcionando con nombre_completo; la nueva ya puede usar nombre/apellidos.
-
Deploy de la app v2 (lee nombre/apellidos, escribe en ambas) Doble escritura durante el periodo de transición para garantizar consistencia.
-
Ejecutar migración 2 (plan_suscripcion) Segura en cualquier momento; el default 'free' no requiere UPDATE previo.
-
Ejecutar migración 3 (UUID nativo) en ventana de menor tráfico Aunque es zero-downtime, el CAST de 143k filas puede tardar ~8-15s — preferir 02:00–04:00.
-
Monitorizar 48h · Logs + Métricas de BD Vigilar query times, deadlocks y errores de constraint. PgBadger o Datadog recomendados.
-
Ejecutar migración 1c (CONTRACT: drop nombre_completo) Solo tras confirmar >48h sin errores y que ningún servicio externo accede a la columna vieja.