Saltar al contenido principal

Sistema de Migraciones

Este documento explica el sistema de migraciones de base de datos de SwapBits.


Vista General del Sistema


1. Estructura del Sistema

Ubicación de Migraciones

migrations/
├── package.json
├── index.js # Runner principal
├── runMigrations.js # Script de ejecución
├── generateKeys.js # Utilidad para generar claves
├── README.md
├── migrations/ # Migraciones
│ ├── 001_create_users_indexes.js
│ ├── 002_add_kyc_fields.js
│ ├── 003_encrypt_private_keys.js
│ ├── 004_add_vip_levels.js
│ └── ...
├── schemas/ # Schemas de MongoDB (compartidos)
│ ├── user.schema.js
│ ├── wallet.schema.js
│ ├── transaction.schema.js
│ └── ...
└── utils/ # Utilidades
├── encryption.js
├── validation.js
└── ...

2. Anatomía de una Migración

Estructura Base

// migrations/005_example_migration.js

module.exports = {
// Identificador único
id: '005_example_migration',

// Descripción
description: 'Add email verification fields to users',

// Función de migración (UP)
async up(db, client) {
console.log('Running migration: Add email verification fields');

// Obtener colección
const usersCollection = db.collection('users');

// Ejecutar cambios
await usersCollection.updateMany(
{ emailVerified: { $exists: false } },
{
$set: {
emailVerified: false,
emailVerificationToken: null,
emailVerificationExpires: null
}
}
);

// Crear índice si es necesario
await usersCollection.createIndex({ emailVerificationToken: 1 });

console.log('Migration completed successfully');
},

// Función de rollback (DOWN)
async down(db, client) {
console.log('Rolling back migration: Remove email verification fields');

const usersCollection = db.collection('users');

// Revertir cambios
await usersCollection.updateMany(
{},
{
$unset: {
emailVerified: '',
emailVerificationToken: '',
emailVerificationExpires: ''
}
}
);

// Eliminar índice
await usersCollection.dropIndex('emailVerificationToken_1');

console.log('Rollback completed successfully');
}
};

3. Tipos de Migraciones

A. Agregar Campo Nuevo

// migrations/010_add_two_factor_auth.js
module.exports = {
id: '010_add_two_factor_auth',
description: 'Add 2FA fields to users',

async up(db) {
await db.collection('users').updateMany(
{ twoFactorEnabled: { $exists: false } },
{
$set: {
twoFactorEnabled: false,
twoFactorSecret: null,
backupCodes: []
}
}
);
},

async down(db) {
await db.collection('users').updateMany(
{},
{
$unset: {
twoFactorEnabled: '',
twoFactorSecret: '',
backupCodes: ''
}
}
);
}
};

B. Transformar Datos Existentes

// migrations/015_normalize_phone_numbers.js
module.exports = {
id: '015_normalize_phone_numbers',
description: 'Normalize phone numbers to E.164 format',

async up(db) {
const users = db.collection('users');
const cursor = users.find({ phone: { $exists: true, $ne: null } });

let count = 0;

while (await cursor.hasNext()) {
const user = await cursor.next();

// Normalizar teléfono (ejemplo: +1234567890)
const normalized = normalizePhoneNumber(user.phone);

if (normalized !== user.phone) {
await users.updateOne(
{ _id: user._id },
{ $set: { phone: normalized } }
);
count++;
}
}

console.log(`Normalized ${count} phone numbers`);
},

async down(db) {
// Difícil de revertir transformaciones
console.log('Cannot rollback phone normalization');
}
};

function normalizePhoneNumber(phone) {
// Remover caracteres no numéricos
const digits = phone.replace(/\D/g, '');

// Agregar código de país si no tiene
if (!digits.startsWith('1') && digits.length === 10) {
return '+1' + digits;
}

return '+' + digits;
}

C. Crear Índices

// migrations/020_optimize_transaction_queries.js
module.exports = {
id: '020_optimize_transaction_queries',
description: 'Create indexes for common transaction queries',

async up(db) {
const transactions = db.collection('transactions');

// Índice compuesto para queries por usuario y fecha
await transactions.createIndex(
{ userId: 1, timestamp: -1 },
{ background: true } // No bloquear la DB
);

// Índice para búsqueda por hash
await transactions.createIndex(
{ txHash: 1 },
{ unique: true, background: true }
);

// Índice para queries por estado
await transactions.createIndex(
{ status: 1, createdAt: -1 },
{ background: true }
);

console.log('Indexes created successfully');
},

async down(db) {
const transactions = db.collection('transactions');

await transactions.dropIndex('userId_1_timestamp_-1');
await transactions.dropIndex('txHash_1');
await transactions.dropIndex('status_1_createdAt_-1');

console.log('Indexes dropped');
}
};

D. Migración con Transacciones

// migrations/025_split_user_balances.js
module.exports = {
id: '025_split_user_balances',
description: 'Split main balance into available and locked',

async up(db, client) {
// Usar transacción para asegurar consistencia
const session = client.startSession();

try {
await session.withTransaction(async () => {
const wallets = db.collection('wallets');

// Actualizar todos los wallets
await wallets.updateMany(
{
balance: { $exists: true },
availableBalance: { $exists: false }
},
[
{
$set: {
availableBalance: '$balance',
lockedBalance: 0
}
}
],
{ session }
);
});

console.log('Balance split completed');

} finally {
await session.endSession();
}
},

async down(db, client) {
const session = client.startSession();

try {
await session.withTransaction(async () => {
const wallets = db.collection('wallets');

await wallets.updateMany(
{},
{
$unset: {
availableBalance: '',
lockedBalance: ''
}
},
{ session }
);
});

} finally {
await session.endSession();
}
}
};

E. Encriptar Datos Sensibles

// migrations/030_encrypt_private_keys.js
const crypto = require('crypto');

module.exports = {
id: '030_encrypt_private_keys',
description: 'Encrypt all private keys in wallets',

async up(db) {
const wallets = db.collection('wallets');
const cursor = wallets.find({
privateKey: { $exists: true },
privateKeyEncrypted: { $exists: false }
});

const masterKey = process.env.WALLET_MASTER_KEY;
let count = 0;

while (await cursor.hasNext()) {
const wallet = await cursor.next();

// Encriptar clave privada
const encrypted = encryptPrivateKey(wallet.privateKey, masterKey);

// Actualizar documento
await wallets.updateOne(
{ _id: wallet._id },
{
$set: { privateKeyEncrypted: encrypted },
$unset: { privateKey: '' } // Remover clave en texto plano
}
);

count++;

if (count % 100 === 0) {
console.log(`Encrypted ${count} private keys...`);
}
}

console.log(`Total encrypted: ${count} private keys`);
},

async down(db) {
// NO IMPLEMENTAR ROLLBACK POR SEGURIDAD
throw new Error('Cannot rollback encryption migration for security reasons');
}
};

function encryptPrivateKey(privateKey, masterKey) {
const algorithm = 'aes-256-gcm';
const iv = crypto.randomBytes(16);
const key = crypto.scryptSync(masterKey, 'salt', 32);

const cipher = crypto.createCipheriv(algorithm, key, iv);

let encrypted = cipher.update(privateKey, 'utf8', 'hex');
encrypted += cipher.final('hex');

const authTag = cipher.getAuthTag();

return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
}

4. Runner de Migraciones

Script Principal

// runMigrations.js
const { MongoClient } = require('mongodb');
const fs = require('fs');
const path = require('path');

async function runMigrations() {
const uri = process.env.MONGODB_URI;
const client = new MongoClient(uri);

try {
await client.connect();
console.log('Connected to MongoDB');

const db = client.db();

// Crear colección de migraciones si no existe
const migrationsCollection = db.collection('_migrations');

// Obtener migraciones ya ejecutadas
const executedMigrations = await migrationsCollection
.find({})
.sort({ executedAt: 1 })
.toArray();

const executedIds = new Set(executedMigrations.map(m => m.migrationId));

// Leer archivos de migraciones
const migrationsDir = path.join(__dirname, 'migrations');
const files = fs.readdirSync(migrationsDir)
.filter(f => f.endsWith('.js'))
.sort();

console.log(`Found ${files.length} migration files`);
console.log(`${executedIds.size} already executed`);

// Ejecutar migraciones pendientes
for (const file of files) {
const migration = require(path.join(migrationsDir, file));

if (executedIds.has(migration.id)) {
console.log(`⏭️ Skipping ${migration.id} (already executed)`);
continue;
}

console.log(`\n▶️ Running ${migration.id}`);
console.log(` ${migration.description}`);

const startTime = Date.now();

try {
// Ejecutar migración
await migration.up(db, client);

// Registrar como ejecutada
await migrationsCollection.insertOne({
migrationId: migration.id,
description: migration.description,
executedAt: new Date(),
executionTime: Date.now() - startTime
});

console.log(`✅ Completed in ${Date.now() - startTime}ms`);

} catch (error) {
console.error(`❌ Migration ${migration.id} failed:`, error);
throw error;
}
}

console.log('\n🎉 All migrations completed successfully!');

} catch (error) {
console.error('Migration error:', error);
process.exit(1);

} finally {
await client.close();
}
}

// Ejecutar si se llama directamente
if (require.main === module) {
runMigrations();
}

module.exports = { runMigrations };

5. Rollback de Migraciones

Script de Rollback

// rollbackMigration.js
async function rollbackLastMigration() {
const client = new MongoClient(process.env.MONGODB_URI);

try {
await client.connect();
const db = client.db();
const migrationsCollection = db.collection('_migrations');

// Obtener última migración ejecutada
const lastMigration = await migrationsCollection
.findOne({}, { sort: { executedAt: -1 } });

if (!lastMigration) {
console.log('No migrations to rollback');
return;
}

console.log(`Rolling back: ${lastMigration.migrationId}`);

// Cargar archivo de migración
const migration = require(`./migrations/${lastMigration.migrationId}.js`);

// Ejecutar rollback
await migration.down(db, client);

// Eliminar de registro
await migrationsCollection.deleteOne({ _id: lastMigration._id });

console.log('✅ Rollback completed');

} catch (error) {
console.error('Rollback error:', error);
throw error;

} finally {
await client.close();
}
}

6. Testing de Migraciones

Test Suite

// migrations/__tests__/migrations.test.js
const { MongoMemoryServer } = require('mongodb-memory-server');
const { MongoClient } = require('mongodb');

describe('Migrations', () => {
let mongoServer;
let client;
let db;

beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const uri = mongoServer.getUri();

client = new MongoClient(uri);
await client.connect();
db = client.db();
});

afterAll(async () => {
await client.close();
await mongoServer.stop();
});

test('010_add_two_factor_auth should add 2FA fields', async () => {
// Setup: Crear usuario sin 2FA
await db.collection('users').insertOne({
email: 'test@example.com',
password: 'hashed'
});

// Ejecutar migración
const migration = require('../migrations/010_add_two_factor_auth');
await migration.up(db, client);

// Verificar
const user = await db.collection('users').findOne({ email: 'test@example.com' });

expect(user.twoFactorEnabled).toBe(false);
expect(user.twoFactorSecret).toBeNull();
expect(user.backupCodes).toEqual([]);
});

test('010_add_two_factor_auth rollback should remove 2FA fields', async () => {
const migration = require('../migrations/010_add_two_factor_auth');

// Ejecutar rollback
await migration.down(db, client);

// Verificar
const user = await db.collection('users').findOne({ email: 'test@example.com' });

expect(user.twoFactorEnabled).toBeUndefined();
expect(user.twoFactorSecret).toBeUndefined();
expect(user.backupCodes).toBeUndefined();
});
});

7. Migraciones en CI/CD

GitHub Actions Workflow

# .github/workflows/migrations.yml
name: Run Migrations

on:
push:
branches: [main]
paths:
- 'migrations/migrations/**'

jobs:
migrate-staging:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'

- name: Install dependencies
run: |
cd migrations
npm install

- name: Run migrations on Staging
env:
MONGODB_URI: ${{ secrets.MONGODB_URI_STAGING }}
WALLET_MASTER_KEY: ${{ secrets.WALLET_MASTER_KEY }}
run: |
cd migrations
node runMigrations.js

- name: Notify success
if: success()
run: echo "Migrations applied successfully to staging"

- name: Notify failure
if: failure()
run: echo "Migration failed! Rolling back..."

migrate-production:
needs: migrate-staging
runs-on: ubuntu-latest
environment: production

steps:
- uses: actions/checkout@v3

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'

- name: Run migrations on Production
env:
MONGODB_URI: ${{ secrets.MONGODB_URI_PRODUCTION }}
WALLET_MASTER_KEY: ${{ secrets.WALLET_MASTER_KEY_PROD }}
run: |
cd migrations
node runMigrations.js

- name: Create backup before migration
run: |
mongodump --uri="${{ secrets.MONGODB_URI_PRODUCTION }}" \
--out=backup-$(date +%Y%m%d-%H%M%S)

8. Best Practices

✅ Hacer

// 1. Migraciones idempotentes (se pueden ejecutar múltiples veces)
async up(db) {
await db.collection('users').updateMany(
{ emailVerified: { $exists: false } }, // Solo si no existe
{ $set: { emailVerified: false } }
);
}

// 2. Usar índices en background
await collection.createIndex(
{ userId: 1, timestamp: -1 },
{ background: true } // No bloquear DB
);

// 3. Procesar en lotes para datos grandes
const batchSize = 1000;
const cursor = collection.find({});

while (await cursor.hasNext()) {
const batch = [];

for (let i = 0; i < batchSize && await cursor.hasNext(); i++) {
batch.push(await cursor.next());
}

// Procesar batch
await processBatch(batch);
}

// 4. Logging detallado
console.log(`Processing ${count} documents...`);
if (count % 1000 === 0) {
console.log(`Progress: ${count} documents processed`);
}

❌ Evitar

// 1. NO hacer migraciones destructivas sin backup
async up(db) {
// MAL: Eliminar datos sin backup
await db.collection('old_table').drop();
}

// 2. NO usar .find() sin límite en tablas grandes
async up(db) {
// MAL: Puede consumir toda la memoria
const allUsers = await db.collection('users').find({}).toArray();
}

// 3. NO hacer rollbacks destructivos
async down(db) {
// MAL: No se puede recuperar datos eliminados
await db.collection('users').deleteMany({ role: 'deleted' });
}

// 4. NO ejecutar migraciones sin testear primero
// SIEMPRE testear en entorno local/staging antes de producción

9. Registro de Migraciones

Colección de Control

// _migrations collection
{
_id: ObjectId("..."),
migrationId: "025_split_user_balances",
description: "Split main balance into available and locked",
executedAt: ISODate("2025-10-20T10:00:00Z"),
executionTime: 1234, // ms
executedBy: "admin@swapbits.com",
environment: "production",
success: true
}

10. Comandos Útiles

# Ejecutar migraciones
cd migrations
node runMigrations.js

# Ejecutar migración específica
node runMigrations.js --only 025_split_user_balances

# Rollback última migración
node rollbackMigration.js

# Rollback hasta migración específica
node rollbackMigration.js --to 020_optimize_transaction_queries

# Ver estado de migraciones
node statusMigrations.js

# Generar nueva migración
npm run generate-migration "add user preferences"
# Crea: migrations/031_add_user_preferences.js

Precauciones Críticas

Antes de ejecutar migraciones en producción:

  1. SIEMPRE hacer backup completo de la base de datos
  2. Testear en staging con datos reales
  3. Revisar plan de rollback si algo sale mal
  4. Notificar al equipo del mantenimiento
  5. Ejecutar en horario de bajo tráfico (2-4 AM)
  6. Monitorear durante y después de la migración
  7. Tener plan B si falla

Migraciones peligrosas que requieren extra cuidado:

  • 🔴 Eliminar columnas/colecciones
  • 🔴 Cambiar tipos de datos
  • 🔴 Transformar datos masivamente
  • 🔴 Crear índices únicos (puede fallar si hay duplicados)
  • 🔴 Encriptar/desencriptar datos

Si una migración falla:

  1. NO entrar en pánico
  2. Revisar logs de error
  3. Ejecutar rollback si es posible
  4. Restaurar backup si es necesario
  5. Investigar causa raíz
  6. Corregir migración
  7. Re-testear en staging
  8. Reintentar