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
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;
|
|
}
|
|
}
|
|
|
|
|
|
|