/** * 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}'`, ); // Reduced logging - only log on error } catch (error) { validation.isValid = false; validation.errors.push(`Table ${tableName} missing`); logger.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; // Reduced logging - only log on error } catch (error) { validation.isValid = false; validation.errors.push( `Column iViewContent missing from contacts table`, ); logger.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}`); logger.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 unknown as { values: unknown[][] }; const hasTable = result?.values?.length > 0 || (Array.isArray(result) && result.length > 0); // Reduced logging - only log on error 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`); // Reduced logging - only log on error return true; } catch (error) { // Reduced logging - only log on error 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) { logger.error( `šŸ” [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 { logger.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 await sqlExec(` CREATE TABLE IF NOT EXISTS migrations ( name TEXT PRIMARY KEY, applied_at TEXT DEFAULT CURRENT_TIMESTAMP ); `); // Step 2: Get list of already applied migrations const appliedMigrationsResult = await sqlQuery( "SELECT name FROM migrations", ); const appliedMigrations = extractMigrationNames(appliedMigrationsResult); // Step 3: Get all registered migrations const migrations = migrationRegistry.getMigrations(); if (migrations.length === 0) { logger.warn("āš ļø [Migration] No migrations registered"); return; } logger.log( `šŸ“Š [Migration] Found ${migrations.length} total migrations, ${appliedMigrations.size} already applied`, ); let appliedCount = 0; let skippedCount = 0; // Step 4: Process each migration for (const migration of migrations) { // 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); // Skip if already recorded as applied if (isRecordedAsApplied) { skippedCount++; continue; } // Handle case where schema exists but isn't recorded if (isSchemaPresent) { try { await sqlExec("INSERT INTO migrations (name) VALUES (?)", [ migration.name, ]); logger.log( `āœ… [Migration] Marked existing schema as applied: ${migration.name}`, ); skippedCount++; continue; } catch (insertError) { logger.warn( `āš ļø [Migration] Could not record existing schema ${migration.name}:`, insertError, ); // Continue with normal migration process as fallback } } // Apply the migration logger.log(`šŸ”„ [Migration] Applying migration: ${migration.name}`); try { // Execute the migration SQL await sqlExec(migration.sql); // Validate the migration was applied correctly const validation = await validateMigrationApplication( migration, sqlQuery, ); if (!validation.isValid) { logger.warn( `āš ļø [Migration] Validation failed for ${migration.name}:`, validation.errors, ); } // Record that the migration was applied await sqlExec("INSERT INTO migrations (name) VALUES (?)", [ migration.name, ]); logger.log(`šŸŽ‰ [Migration] Successfully applied: ${migration.name}`); appliedCount++; } catch (error) { logger.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")) ) { logger.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) { logger.warn( `āš ļø [Migration] Schema validation failed for ${migration.name}:`, validation.errors, ); } // Mark the migration as applied since the schema change already exists try { await sqlExec("INSERT INTO migrations (name) VALUES (?)", [ migration.name, ]); logger.log(`āœ… [Migration] Marked as applied: ${migration.name}`); appliedCount++; } catch (insertError) { // If we can't insert the migration record, log it but don't fail logger.warn( `āš ļø [Migration] Could not record ${migration.name} as applied:`, insertError, ); } } else { // For other types of errors, still fail the migration logger.error( `āŒ [Migration] Failed to apply ${migration.name}:`, error, ); throw new Error(`Migration ${migration.name} failed: ${error}`); } } } // Step 5: Final validation - verify all migrations are properly recorded const finalMigrationsResult = await sqlQuery("SELECT name FROM migrations"); const finalAppliedMigrations = extractMigrationNames(finalMigrationsResult); // 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) { logger.warn( `āš ļø [Migration] Missing migration records: ${missingMigrations.join(", ")}`, ); } logger.log( `šŸŽ‰ [Migration] Migration process complete! Summary: ${appliedCount} applied, ${skippedCount} skipped`, ); } catch (error) { logger.error("\nšŸ’„ [Migration] Migration process failed:", error); logger.error("[MigrationService] Migration process failed:", error); throw error; } }