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:
- ✅ SIEMPRE hacer backup completo de la base de datos
- ✅ Testear en staging con datos reales
- ✅ Revisar plan de rollback si algo sale mal
- ✅ Notificar al equipo del mantenimiento
- ✅ Ejecutar en horario de bajo tráfico (2-4 AM)
- ✅ Monitorear durante y después de la migración
- ✅ 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:
- NO entrar en pánico
- Revisar logs de error
- Ejecutar rollback si es posible
- Restaurar backup si es necesario
- Investigar causa raíz
- Corregir migración
- Re-testear en staging
- Reintentar