/** * Database Migration Service for TimeSafari * * Manages database migrations as people upgrade their app over time. * Provides safe, tracked migrations with rollback capabilities and * detailed logging for debugging. * * @author Matthew Raymer */ import { logger } from "../utils/logger"; /** * Migration interface for database schema migrations */ interface Migration { name: string; sql: string; } /** * Migration registry to store and manage database migrations */ class MigrationRegistry { private migrations: Migration[] = []; /** * Register a migration with the registry * * @param migration - The migration to register */ registerMigration(migration: Migration): void { this.migrations.push(migration); } /** * Get all registered migrations * * @returns Array of registered migrations */ getMigrations(): Migration[] { return this.migrations; } /** * Clear all registered migrations */ clearMigrations(): void { this.migrations = []; } } // Create a singleton instance of the migration registry const migrationRegistry = new MigrationRegistry(); /** * Register a migration with the migration service * * This function is used by the migration system to register database * schema migrations that need to be applied to the database. * * @param migration - The migration to register */ export function registerMigration(migration: Migration): void { migrationRegistry.registerMigration(migration); } /** * Run all registered migrations against the database * * This function executes all registered migrations in order, checking * which ones have already been applied to avoid duplicate execution. * It creates a migrations table if it doesn't exist to track applied * migrations. * * @param sqlExec - Function to execute SQL statements * @param sqlQuery - Function to query SQL data * @param extractMigrationNames - Function to extract migration names from query results * @returns Promise that resolves when all migrations are complete */ export async function runMigrations( sqlExec: (sql: string, params?: unknown[]) => Promise, sqlQuery: (sql: string, params?: unknown[]) => Promise, extractMigrationNames: (result: T) => Set, ): Promise { try { console.log("📋 [Migration] Checking migration status..."); // Create migrations table if it doesn't exist await sqlExec(` CREATE TABLE IF NOT EXISTS migrations ( name TEXT PRIMARY KEY, applied_at TEXT DEFAULT CURRENT_TIMESTAMP ); `); // Get list of already applied migrations const appliedMigrationsResult = await sqlQuery( "SELECT name FROM migrations", ); const appliedMigrations = extractMigrationNames(appliedMigrationsResult); // Get all registered migrations const migrations = migrationRegistry.getMigrations(); if (migrations.length === 0) { console.log("⚠️ [Migration] No migrations registered"); logger.warn("[MigrationService] No migrations registered"); return; } console.log(`📊 [Migration] Found ${migrations.length} total migrations, ${appliedMigrations.size} already applied`); let appliedCount = 0; let skippedCount = 0; // Run each migration that hasn't been applied yet for (const migration of migrations) { if (appliedMigrations.has(migration.name)) { console.log(`⏭️ [Migration] Skipping already applied: ${migration.name}`); skippedCount++; continue; } console.log(`🔄 [Migration] Applying migration: ${migration.name}`); try { // Special handling for column addition migrations if (migration.sql.includes("ALTER TABLE") && migration.sql.includes("ADD COLUMN")) { await handleColumnAddition(migration, sqlExec, sqlQuery); } else { // Execute the migration SQL await sqlExec(migration.sql); } // Record that the migration was applied await sqlExec("INSERT INTO migrations (name) VALUES (?)", [ migration.name, ]); console.log(`✅ [Migration] Successfully applied: ${migration.name}`); logger.info( `[MigrationService] Successfully applied migration: ${migration.name}`, ); appliedCount++; } catch (error) { // Handle specific cases where the migration might be partially applied const errorMessage = String(error).toLowerCase(); // Check if it's a duplicate column error - this means the column already exists if ( errorMessage.includes("duplicate column") || errorMessage.includes("column already exists") || errorMessage.includes("already exists") ) { console.log(`⚠️ [Migration] ${migration.name} appears already applied (${errorMessage}). Marking as complete.`); logger.warn( `[MigrationService] Migration ${migration.name} appears to be already applied (${errorMessage}). Marking as complete.`, ); // Mark the migration as applied since the schema change already exists try { await sqlExec("INSERT INTO migrations (name) VALUES (?)", [ migration.name, ]); console.log(`✅ [Migration] Marked as applied: ${migration.name}`); logger.info( `[MigrationService] Successfully marked migration as applied: ${migration.name}`, ); appliedCount++; } catch (insertError) { // If we can't insert the migration record, log it but don't fail console.warn(`⚠️ [Migration] Could not record ${migration.name} as applied:`, insertError); logger.warn( `[MigrationService] Could not record migration ${migration.name} as applied:`, insertError, ); } } else { // For other types of errors, still fail the migration console.error(`❌ [Migration] Failed to apply ${migration.name}:`, error); logger.error( `[MigrationService] Failed to apply migration ${migration.name}:`, error, ); throw new Error(`Migration ${migration.name} failed: ${error}`); } } } console.log(`🎉 [Migration] Migration process complete! Applied: ${appliedCount}, Skipped: ${skippedCount}`); } catch (error) { console.error("💥 [Migration] Migration process failed:", error); logger.error("[MigrationService] Migration process failed:", error); throw error; } } /** * Handle column addition migrations with proper existence checking * * @param migration - The migration containing column addition * @param sqlExec - Function to execute SQL statements * @param sqlQuery - Function to query SQL data */ async function handleColumnAddition( migration: Migration, sqlExec: (sql: string, params?: unknown[]) => Promise, sqlQuery: (sql: string, params?: unknown[]) => Promise, ): Promise { // Extract table name and column name from ALTER TABLE statement const alterMatch = migration.sql.match(/ALTER TABLE\s+(\w+)\s+ADD COLUMN\s+(\w+)/i); if (!alterMatch) { // If we can't parse it, just try to execute normally await sqlExec(migration.sql); return; } const [, tableName, columnName] = alterMatch; try { // Check if column already exists const columnCheckResult = await sqlQuery( `SELECT COUNT(*) as count FROM pragma_table_info('${tableName}') WHERE name = ?`, [columnName] ) as any; const columnExists = columnCheckResult?.values?.[0]?.count > 0 || columnCheckResult?.count > 0 || (Array.isArray(columnCheckResult) && columnCheckResult.length > 0); if (columnExists) { console.log(`⏭️ [Migration] Column ${columnName} already exists in table ${tableName}, skipping`); return; } // Column doesn't exist, so add it console.log(`🔄 [Migration] Adding column ${columnName} to table ${tableName}`); await sqlExec(migration.sql); console.log(`✅ [Migration] Successfully added column ${columnName} to table ${tableName}`); } catch (error) { // If the column check fails, try the original migration and let error handling catch duplicates console.log(`⚠️ [Migration] Column check failed, attempting migration anyway: ${error}`); await sqlExec(migration.sql); } }