diff --git a/src/db-sql/migration.ts b/src/db-sql/migration.ts index 3d849f3f..bb6f72e5 100644 --- a/src/db-sql/migration.ts +++ b/src/db-sql/migration.ts @@ -1,3 +1,12 @@ +/** + * Database Migration System for TimeSafari + * + * This module manages database schema migrations as users upgrade their app. + * It ensures that database changes are applied safely and only when needed. + * + * @author Matthew Raymer + */ + import { registerMigration, runMigrations as runMigrationsService, @@ -35,6 +44,7 @@ const MIGRATIONS = [ { name: "001_initial", sql: ` + -- Create accounts table only if it doesn't exist CREATE TABLE IF NOT EXISTS accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, dateCreated TEXT NOT NULL, @@ -48,13 +58,16 @@ const MIGRATIONS = [ CREATE INDEX IF NOT EXISTS idx_accounts_did ON accounts(did); + -- Create secret table only if it doesn't exist CREATE TABLE IF NOT EXISTS secret ( id INTEGER PRIMARY KEY AUTOINCREMENT, secretBase64 TEXT NOT NULL ); + -- Insert secret only if table is empty INSERT OR IGNORE INTO secret (id, secretBase64) VALUES (1, '${secretBase64}'); + -- Create settings table only if it doesn't exist CREATE TABLE IF NOT EXISTS settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, accountDid TEXT, @@ -89,8 +102,10 @@ const MIGRATIONS = [ CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid); + -- Insert default settings only if table is empty INSERT OR IGNORE INTO settings (id, apiServer) VALUES (1, '${DEFAULT_ENDORSER_API_SERVER}'); + -- Create contacts table only if it doesn't exist CREATE TABLE IF NOT EXISTS contacts ( id INTEGER PRIMARY KEY AUTOINCREMENT, did TEXT NOT NULL, @@ -107,11 +122,13 @@ const MIGRATIONS = [ CREATE INDEX IF NOT EXISTS idx_contacts_did ON contacts(did); CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name); + -- Create logs table only if it doesn't exist CREATE TABLE IF NOT EXISTS logs ( date TEXT NOT NULL, message TEXT NOT NULL ); + -- Create temp table only if it doesn't exist CREATE TABLE IF NOT EXISTS temp ( id TEXT PRIMARY KEY, blobB64 TEXT @@ -121,27 +138,41 @@ const MIGRATIONS = [ { name: "002_add_iViewContent_to_contacts", sql: ` - -- We need to handle the case where iViewContent column might already exist - -- SQLite doesn't support IF NOT EXISTS for ALTER TABLE ADD COLUMN - -- So we'll use a more robust approach with error handling in the migration service - - -- First, try to add the column - this will fail silently if it already exists + -- Add iViewContent column to contacts table + -- The migration service will check if the column already exists ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE; `, }, ]; /** + * Runs all registered database migrations + * + * This function ensures that the database schema is up-to-date by running + * all pending migrations. It uses the migration service to track which + * migrations have been applied and avoid running them multiple times. + * * @param sqlExec - A function that executes a SQL statement and returns the result - * @param extractMigrationNames - A function that extracts the names (string array) from "select name from migrations" + * @param sqlQuery - A function that executes a SQL query and returns the result + * @param extractMigrationNames - A function that extracts 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 { + console.log("🔄 [Migration] Starting database migration process..."); + for (const migration of MIGRATIONS) { registerMigration(migration); } - await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames); + + try { + await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames); + console.log("✅ [Migration] Database migration process completed successfully"); + } catch (error) { + console.error("❌ [Migration] Database migration process failed:", error); + throw error; + } } diff --git a/src/services/migrationService.ts b/src/services/migrationService.ts index 3b31958f..4ff38231 100644 --- a/src/services/migrationService.ts +++ b/src/services/migrationService.ts @@ -1,5 +1,11 @@ /** - * Manage database migrations as people upgrade their app over time + * 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"; @@ -78,6 +84,8 @@ export async function runMigrations( 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 ( @@ -96,28 +104,45 @@ export async function runMigrations( 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 { - // Execute the migration SQL - await sqlExec(migration.sql); + // 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(); @@ -128,6 +153,7 @@ export async function runMigrations( 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.`, ); @@ -137,11 +163,14 @@ export async function runMigrations( 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, @@ -149,6 +178,7 @@ export async function runMigrations( } } 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, @@ -157,8 +187,62 @@ export async function runMigrations( } } } + + 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); + } +}