import type { Database } from 'bun:sqlite'; import { mkdirSync, appendFileSync } from 'fs'; import { join } from 'path'; import { migrations, type Migration } from './migrations'; const MIGRATIONS_LOG_LEVEL = (process.env.MIGRATIONS_LOG_LEVEL || '').toLowerCase(); const MIGRATIONS_QUIET = process.env.NODE_ENV === 'test' || MIGRATIONS_LOG_LEVEL === 'silent'; function nowIso(): string { return new Date().toISOString().replace('T', ' ').replace('Z', ''); } function logEvent(level: 'info' | 'error', event: string, data: any = {}) { // En modo test o nivel 'silent', no registrar eventos para evitar ruido if (MIGRATIONS_QUIET) return; try { mkdirSync('data', { recursive: true }); } catch {} try { const line = JSON.stringify({ ts: nowIso(), level, event, ...data }); appendFileSync(join('data', 'migrations.log'), line + '\n'); } catch {} } function ensureMigrationsTable(db: Database) { db.exec(` CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY, name TEXT NOT NULL, checksum TEXT NOT NULL, applied_at TEXT NOT NULL ); `); } function getAppliedVersions(db: Database): Map { const rows = db.query(`SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`).all() as any[]; const map = new Map(); for (const r of rows) { map.set(Number(r.version), { name: String(r.name), checksum: String(r.checksum), applied_at: String(r.applied_at) }); } return map; } function tableExists(db: Database, table: string): boolean { const row = db.query(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`).get(table) as any; return !!row; } function detectExistingSchema(db: Database): boolean { // Consideramos esquema "existente" si ya hay tablas clave const coreTables = ['users', 'tasks', 'response_queue']; return coreTables.every(t => tableExists(db, t)); } function insertMigrationRow(db: Database, mig: Migration) { db.prepare( `INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)` ).run(mig.version, mig.name, mig.checksum, nowIso()); } function backupDatabaseIfNeeded(db: Database): string | null { if (process.env.NODE_ENV === 'test') return null; try { mkdirSync('data', { recursive: true }); } catch {} const stamp = new Date().toISOString().replace(/[-:T.Z]/g, '').slice(0, 14); const backupPath = join('data', `tasks.db.bak-${stamp}.sqlite`); try { // VACUUM INTO hace copia consistente del estado actual db.exec(`VACUUM INTO '${backupPath.replace(/'/g, "''")}'`); if (!MIGRATIONS_QUIET) console.log(`ℹ️ Backup de base de datos creado en: ${backupPath}`); return backupPath; } catch (e) { if (!MIGRATIONS_QUIET) console.warn('⚠️ No se pudo crear el backup con VACUUM INTO (continuando de todos modos):', e); return null; } } export const Migrator = { ensureMigrationsTable, getAppliedVersions, migrateToLatest(db: Database, options?: { withBackup?: boolean; allowBaseline?: boolean }) { const withBackup = options?.withBackup !== false; const allowBaseline = options?.allowBaseline === true; ensureMigrationsTable(db); const applied = getAppliedVersions(db); const pending = migrations.filter(m => !applied.has(m.version)).sort((a, b) => a.version - b.version); // Validación de checksum (estricta por defecto, configurable) const strict = (process.env.MIGRATOR_CHECKSUM_STRICT ?? 'true').toLowerCase() !== 'false'; for (const [version, info] of applied) { const codeMig = migrations.find(m => m.version === version); if (codeMig && codeMig.checksum !== info.checksum) { const msg = `❌ Checksum mismatch en migración v${version}: aplicado=${info.checksum}, código=${codeMig.checksum}`; console.error(msg); try { logEvent('error', 'checksum_mismatch', { version, applied_checksum: info.checksum, code_checksum: codeMig.checksum }); } catch {} if (strict) throw new Error(msg); } } // Resumen inicial const jmRow = db.query(`PRAGMA journal_mode`).get() as any; const journalMode = jmRow ? (jmRow.journal_mode || jmRow.value || jmRow.mode || 'unknown') : 'unknown'; const currentVersion = applied.size ? Math.max(...Array.from(applied.keys())) : 0; if (!MIGRATIONS_QUIET) console.log(`ℹ️ Migrador — journal_mode=${journalMode}, versión_actual=${currentVersion}, pendientes=${pending.length}`); try { logEvent('info', 'startup_summary', { journal_mode: journalMode, current_version: currentVersion, pending: pending.length }); } catch {} if (applied.size === 0 && allowBaseline && detectExistingSchema(db)) { // Baseline a v1 si ya existe el esquema pero no hay registro const v1 = migrations.find(m => m.version === 1)!; db.transaction(() => { insertMigrationRow(db, v1); })(); if (!MIGRATIONS_QUIET) console.log('ℹ️ Baseline aplicado: schema_migrations marcada en v1 (sin ejecutar up)'); try { logEvent('info', 'baseline_applied', { version: 1 }); } catch {} // Recalcular pendientes pending.splice(0, pending.length, ...migrations.filter(m => m.version > 1)); } if (pending.length === 0) { if (!MIGRATIONS_QUIET) console.log('ℹ️ No hay migraciones pendientes'); try { logEvent('info', 'no_pending', {}); } catch {} return; } if (withBackup) { const backupPath = backupDatabaseIfNeeded(db); try { logEvent('info', 'backup', { path: backupPath }); } catch {} } for (const mig of pending) { if (!MIGRATIONS_QUIET) console.log(`➡️ Aplicando migración v${mig.version} - ${mig.name}`); try { try { logEvent('info', 'apply_start', { version: mig.version, name: mig.name, checksum: mig.checksum }); } catch {} const t0 = Date.now(); db.transaction(() => { // Ejecutar up const res = mig.up(db); if (res instanceof Promise) { throw new Error('Las migraciones up no deben ser asíncronas en este migrador'); } // Registrar insertMigrationRow(db, mig); })(); const ms = Date.now() - t0; if (!MIGRATIONS_QUIET) console.log(`✅ Migración v${mig.version} aplicada (${ms} ms)`); try { logEvent('info', 'apply_success', { version: mig.version, name: mig.name, checksum: mig.checksum, duration_ms: ms }); } catch {} } catch (e) { console.error(`❌ Error aplicando migración v${mig.version}:`, e); try { logEvent('error', 'apply_error', { version: mig.version, name: mig.name, checksum: mig.checksum, error: String(e) }); } catch {} throw e; } } } };