|  |  | 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<number, { name: string; checksum: string; applied_at: string }> {
 | 
						
						
						
							|  |  |   const rows = db.query(`SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`).all() as any[];
 | 
						
						
						
							|  |  |   const map = new Map<number, { name: string; checksum: string; applied_at: string }>();
 | 
						
						
						
							|  |  |   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;
 | 
						
						
						
							|  |  |       }
 | 
						
						
						
							|  |  |     }
 | 
						
						
						
							|  |  |   }
 | 
						
						
						
							|  |  | };
 |