CULTIVA IA · Skill — migracion-bases-de-datos

Plan de Migración Zero-Downtime

NutriTrack SaaS · PostgreSQL 15 · Sequelize 6 · Node.js 20
Zero-Downtime Rollback garantizado 18 000 registros PostgreSQL 15 Sequelize CLI Producción 24/7
1
Split de nombre_completo
Dividir 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:undo elimina 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_suscripcion
ENUM 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 citas
Migrar 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.