You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

159 lines
6.7 KiB
TypeScript

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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;
}
}
}
};