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

570 lines
21 KiB

/**
* 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<T>(
migration: Migration,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
): Promise<MigrationValidation> {
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<T>(
migration: Migration,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
): Promise<boolean> {
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<T>(
sqlExec: (sql: string, params?: unknown[]) => Promise<unknown>,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
extractMigrationNames: (result: T) => Set<string>,
): Promise<void> {
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;
}
}