/** * Database Migration Service for TimeSafari * * This module provides a comprehensive database migration system that manages * schema changes as users upgrade their TimeSafari application over time. * The system ensures that database changes are applied safely, tracked properly, * and can handle edge cases gracefully. * * ## Architecture Overview * * The migration system follows these key principles: * * 1. **Single Application**: Each migration runs exactly once per database * 2. **Tracked Execution**: All applied migrations are recorded in a migrations table * 3. **Schema Validation**: Actual database schema is validated before and after migrations * 4. **Graceful Recovery**: Handles cases where schema exists but tracking is missing * 5. **Comprehensive Logging**: Detailed logging for debugging and monitoring * * ## Migration Flow * * ``` * 1. Create migrations table (if needed) * 2. Query existing applied migrations * 3. For each registered migration: * a. Check if recorded as applied * b. Check if schema already exists * c. Skip if already applied * d. Apply migration SQL * e. Validate schema was created * f. Record migration as applied * 4. Final validation of all migrations * ``` * * ## Usage Example * * ```typescript * // Register migrations (typically in migration.ts) * registerMigration({ * name: "001_initial", * sql: "CREATE TABLE accounts (id INTEGER PRIMARY KEY, ...)" * }); * * // Run migrations (typically in platform service) * await runMigrations(sqlExec, sqlQuery, extractMigrationNames); * ``` * * ## Error Handling * * The system handles several error scenarios: * - Duplicate table/column errors (schema already exists) * - Migration tracking inconsistencies * - Database connection issues * - Schema validation failures * * @author Matthew Raymer * @version 1.0.0 * @since 2025-06-30 */ import { logger } from "../utils/logger"; /** * Migration interface for database schema migrations * * Represents a single database migration that can be applied to upgrade * the database schema. Each migration should be idempotent and focused * on a single schema change. * * @interface Migration */ interface Migration { /** Unique identifier for the migration (e.g., "001_initial", "002_add_column") */ name: string; /** SQL statement(s) to execute for this migration */ sql: string; } /** * Migration validation result * * Contains the results of validating that a migration was successfully * applied by checking the actual database schema. * * @interface MigrationValidation */ interface MigrationValidation { /** Whether the migration validation passed overall */ isValid: boolean; /** Whether expected tables exist */ tableExists: boolean; /** Whether expected columns exist */ hasExpectedColumns: boolean; /** List of validation errors encountered */ errors: string[]; } /** * Migration registry to store and manage database migrations * * This class maintains a registry of all migrations that need to be applied * to the database. It uses the singleton pattern to ensure migrations are * registered once and can be accessed globally. * * @class MigrationRegistry */ class MigrationRegistry { /** Array of registered migrations */ private migrations: Migration[] = []; /** * Register a migration with the registry * * Adds a migration to the list of migrations that will be applied when * runMigrations() is called. Migrations should be registered in order * of their intended execution. * * @param migration - The migration to register * @throws {Error} If migration name is empty or already exists * * @example * ```typescript * registry.registerMigration({ * name: "001_create_users_table", * sql: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)" * }); * ``` */ registerMigration(migration: Migration): void { if (!migration.name || migration.name.trim() === '') { throw new Error('Migration name cannot be empty'); } if (this.migrations.some(m => m.name === migration.name)) { throw new Error(`Migration with name '${migration.name}' already exists`); } this.migrations.push(migration); } /** * Get all registered migrations * * Returns a copy of all migrations that have been registered with this * registry. The migrations are returned in the order they were registered. * * @returns Array of registered migrations (defensive copy) */ getMigrations(): Migration[] { return [...this.migrations]; } /** * Clear all registered migrations * * Removes all migrations from the registry. This is primarily used for * testing purposes to ensure a clean state between test runs. * * @internal Used primarily for testing */ clearMigrations(): void { this.migrations = []; } /** * Get the count of registered migrations * * @returns Number of migrations currently registered */ getCount(): number { return this.migrations.length; } } // Create a singleton instance of the migration registry const migrationRegistry = new MigrationRegistry(); /** * Register a migration with the migration service * * This is the primary public API for registering database migrations. * Each migration should represent a single, focused schema change that * can be applied atomically. * * @param migration - The migration to register * @throws {Error} If migration is invalid * * @example * ```typescript * registerMigration({ * name: "001_initial_schema", * sql: ` * CREATE TABLE accounts ( * id INTEGER PRIMARY KEY, * did TEXT UNIQUE NOT NULL, * privateKeyHex TEXT NOT NULL, * publicKeyHex TEXT NOT NULL, * derivationPath TEXT, * mnemonic TEXT * ); * ` * }); * ``` */ export function registerMigration(migration: Migration): void { migrationRegistry.registerMigration(migration); } /** * Validate that a migration was successfully applied by checking schema * * This function performs post-migration validation to ensure that the * expected database schema changes were actually applied. It checks for * the existence of tables, columns, and other schema elements that should * have been created by the migration. * * @param migration - The migration to validate * @param sqlQuery - Function to execute SQL queries * @returns Promise resolving to validation results * * @example * ```typescript * const validation = await validateMigrationApplication(migration, sqlQuery); * if (!validation.isValid) { * console.error('Migration validation failed:', validation.errors); * } * ``` */ async function validateMigrationApplication( migration: Migration, sqlQuery: (sql: string, params?: unknown[]) => Promise, ): Promise { const validation: MigrationValidation = { isValid: true, tableExists: false, hasExpectedColumns: false, errors: [] }; try { if (migration.name === "001_initial") { // Validate core tables exist for initial migration const tables = ['accounts', 'secret', 'settings', 'contacts', 'logs', 'temp']; for (const tableName of tables) { try { await sqlQuery(`SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}'`); console.log(`✅ [Migration-Validation] Table ${tableName} exists`); } catch (error) { validation.isValid = false; validation.errors.push(`Table ${tableName} missing`); console.error(`❌ [Migration-Validation] Table ${tableName} missing:`, error); } } validation.tableExists = validation.errors.length === 0; } else if (migration.name === "002_add_iViewContent_to_contacts") { // Validate iViewContent column exists in contacts table try { await sqlQuery(`SELECT iViewContent FROM contacts LIMIT 1`); validation.hasExpectedColumns = true; console.log(`✅ [Migration-Validation] Column iViewContent exists in contacts table`); } catch (error) { validation.isValid = false; validation.errors.push(`Column iViewContent missing from contacts table`); console.error(`❌ [Migration-Validation] Column iViewContent missing:`, error); } } // Add validation for future migrations here // } else if (migration.name === "003_future_migration") { // // Validate future migration schema changes // } } catch (error) { validation.isValid = false; validation.errors.push(`Validation error: ${error}`); console.error(`❌ [Migration-Validation] Validation failed for ${migration.name}:`, error); } return validation; } /** * Check if migration is already applied by examining actual schema * * This function performs schema introspection to determine if a migration * has already been applied, even if it's not recorded in the migrations * table. This is useful for handling cases where the database schema exists * but the migration tracking got out of sync. * * @param migration - The migration to check * @param sqlQuery - Function to execute SQL queries * @returns Promise resolving to true if schema already exists * * @example * ```typescript * const schemaExists = await isSchemaAlreadyPresent(migration, sqlQuery); * if (schemaExists) { * console.log('Schema already exists, skipping migration'); * } * ``` */ async function isSchemaAlreadyPresent( migration: Migration, sqlQuery: (sql: string, params?: unknown[]) => Promise, ): Promise { try { if (migration.name === "001_initial") { // Check if accounts table exists (primary indicator of initial migration) const result = await sqlQuery(`SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'`) as any; const hasTable = result?.values?.length > 0 || (Array.isArray(result) && result.length > 0); console.log(`🔍 [Migration-Schema] Initial migration schema check - accounts table exists: ${hasTable}`); return hasTable; } else if (migration.name === "002_add_iViewContent_to_contacts") { // Check if iViewContent column exists in contacts table try { await sqlQuery(`SELECT iViewContent FROM contacts LIMIT 1`); console.log(`🔍 [Migration-Schema] iViewContent column already exists`); return true; } catch (error) { console.log(`🔍 [Migration-Schema] iViewContent column does not exist`); return false; } } // Add schema checks for future migrations here // } else if (migration.name === "003_future_migration") { // // Check if future migration schema already exists // } } catch (error) { console.log(`🔍 [Migration-Schema] Schema check failed for ${migration.name}, assuming not present:`, error); return false; } return false; } /** * Run all registered migrations against the database * * This is the main function that executes the migration process. It: * 1. Creates the migrations tracking table if needed * 2. Determines which migrations have already been applied * 3. Applies any pending migrations in order * 4. Validates that migrations were applied correctly * 5. Records successful migrations in the tracking table * 6. Performs final validation of the migration state * * The function is designed to be idempotent - it can be run multiple times * safely without re-applying migrations that have already been completed. * * @template T - The type returned by SQL query operations * @param sqlExec - Function to execute SQL statements (INSERT, UPDATE, CREATE, etc.) * @param sqlQuery - Function to execute SQL queries (SELECT) * @param extractMigrationNames - Function to extract migration names from query results * @returns Promise that resolves when all migrations are complete * @throws {Error} If any migration fails to apply * * @example * ```typescript * // Platform-specific implementation * const sqlExec = async (sql: string, params?: unknown[]) => { * return await db.run(sql, params); * }; * * const sqlQuery = async (sql: string, params?: unknown[]) => { * return await db.query(sql, params); * }; * * const extractNames = (result: DBResult) => { * return new Set(result.values.map(row => row[0])); * }; * * await runMigrations(sqlExec, sqlQuery, extractNames); * ``` */ 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] Starting migration process..."); // Step 1: Create migrations table if it doesn't exist // Note: We use IF NOT EXISTS here because this is infrastructure, not a business migration console.log("🔧 [Migration] Creating 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 ); `); console.log("✅ [Migration] Migrations table ready"); // Step 2: Get list of already applied migrations console.log("🔍 [Migration] Querying existing migrations..."); const appliedMigrationsResult = await sqlQuery( "SELECT name FROM migrations", ); console.log("📊 [Migration] Raw query result:", appliedMigrationsResult); const appliedMigrations = extractMigrationNames(appliedMigrationsResult); console.log("📋 [Migration] Extracted applied migrations:", Array.from(appliedMigrations)); // Step 3: 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`); console.log(`📝 [Migration] Registered migrations: ${migrations.map(m => m.name).join(', ')}`); let appliedCount = 0; let skippedCount = 0; // Step 4: Process each migration for (const migration of migrations) { console.log(`\n🔍 [Migration] Processing migration: ${migration.name}`); // Check 1: Is it recorded as applied in migrations table? const isRecordedAsApplied = appliedMigrations.has(migration.name); // Check 2: Does the schema already exist in the database? const isSchemaPresent = await isSchemaAlreadyPresent(migration, sqlQuery); console.log(`🔍 [Migration] ${migration.name} - Recorded: ${isRecordedAsApplied}, Schema: ${isSchemaPresent}`); // Skip if already recorded as applied if (isRecordedAsApplied) { console.log(`⏭️ [Migration] Skipping already applied: ${migration.name}`); skippedCount++; continue; } // Handle case where schema exists but isn't recorded if (isSchemaPresent) { console.log(`🔄 [Migration] Schema exists but not recorded. Marking ${migration.name} as applied...`); try { const insertResult = await sqlExec("INSERT INTO migrations (name) VALUES (?)", [ migration.name, ]); console.log(`✅ [Migration] Migration record inserted:`, insertResult); console.log(`✅ [Migration] Marked existing schema as applied: ${migration.name}`); skippedCount++; continue; } catch (insertError) { console.warn(`⚠️ [Migration] Could not record existing schema ${migration.name}:`, insertError); // Continue with normal migration process as fallback } } // Apply the migration console.log(`🔄 [Migration] Applying migration: ${migration.name}`); try { // Execute the migration SQL console.log(`🔧 [Migration] Executing SQL for ${migration.name}...`); await sqlExec(migration.sql); console.log(`✅ [Migration] SQL executed successfully for ${migration.name}`); // Validate the migration was applied correctly const validation = await validateMigrationApplication(migration, sqlQuery); if (!validation.isValid) { console.warn(`⚠️ [Migration] Validation failed for ${migration.name}:`, validation.errors); } else { console.log(`✅ [Migration] Schema validation passed for ${migration.name}`); } // Record that the migration was applied console.log(`📝 [Migration] Recording migration ${migration.name} as applied...`); const insertResult = await sqlExec("INSERT INTO migrations (name) VALUES (?)", [ migration.name, ]); console.log(`✅ [Migration] Migration record inserted:`, insertResult); console.log(`🎉 [Migration] Successfully applied: ${migration.name}`); logger.info( `[MigrationService] Successfully applied migration: ${migration.name}`, ); appliedCount++; } catch (error) { console.error(`❌ [Migration] Error applying ${migration.name}:`, error); // Handle specific cases where the migration might be partially applied const errorMessage = String(error).toLowerCase(); // Check if it's a duplicate table/column error - this means the schema already exists if ( errorMessage.includes("duplicate column") || errorMessage.includes("column already exists") || errorMessage.includes("already exists") || errorMessage.includes("table") && errorMessage.includes("already exists") ) { console.log(`⚠️ [Migration] ${migration.name} appears already applied (${errorMessage}). Validating and marking as complete.`); // Validate the existing schema const validation = await validateMigrationApplication(migration, sqlQuery); if (validation.isValid) { console.log(`✅ [Migration] Schema validation passed for ${migration.name}`); } else { console.warn(`⚠️ [Migration] Schema validation failed for ${migration.name}:`, validation.errors); } // Mark the migration as applied since the schema change already exists try { console.log(`📝 [Migration] Attempting to record ${migration.name} as applied despite error...`); const insertResult = await sqlExec("INSERT INTO migrations (name) VALUES (?)", [ migration.name, ]); console.log(`✅ [Migration] Migration record inserted after error:`, insertResult); 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}`); } } } // Step 5: Final validation - verify all migrations are properly recorded console.log("\n🔍 [Migration] Final validation - checking migrations table..."); const finalMigrationsResult = await sqlQuery("SELECT name FROM migrations"); const finalAppliedMigrations = extractMigrationNames(finalMigrationsResult); console.log("📋 [Migration] Final applied migrations:", Array.from(finalAppliedMigrations)); // Check that all expected migrations are recorded const expectedMigrations = new Set(migrations.map(m => m.name)); const missingMigrations = [...expectedMigrations].filter(name => !finalAppliedMigrations.has(name)); if (missingMigrations.length > 0) { console.warn(`⚠️ [Migration] Missing migration records: ${missingMigrations.join(', ')}`); logger.warn(`[MigrationService] Missing migration records: ${missingMigrations.join(', ')}`); } console.log(`\n🎉 [Migration] Migration process complete!`); console.log(`📊 [Migration] Summary: Applied: ${appliedCount}, Skipped: ${skippedCount}, Total: ${migrations.length}`); logger.info(`[MigrationService] Migration process complete. Applied: ${appliedCount}, Skipped: ${skippedCount}`); } catch (error) { console.error("\n💥 [Migration] Migration process failed:", error); logger.error("[MigrationService] Migration process failed:", error); throw error; } }