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.
210 lines
7.5 KiB
210 lines
7.5 KiB
/**
|
|
* 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";
|
|
|
|
/**
|
|
* Migration interface for database schema migrations
|
|
*/
|
|
interface Migration {
|
|
name: string;
|
|
sql: string;
|
|
}
|
|
|
|
/**
|
|
* Migration registry to store and manage database migrations
|
|
*/
|
|
class MigrationRegistry {
|
|
private migrations: Migration[] = [];
|
|
|
|
/**
|
|
* Register a migration with the registry
|
|
*
|
|
* @param migration - The migration to register
|
|
*/
|
|
registerMigration(migration: Migration): void {
|
|
this.migrations.push(migration);
|
|
}
|
|
|
|
/**
|
|
* Get all registered migrations
|
|
*
|
|
* @returns Array of registered migrations
|
|
*/
|
|
getMigrations(): Migration[] {
|
|
return this.migrations;
|
|
}
|
|
|
|
/**
|
|
* Clear all registered migrations
|
|
*/
|
|
clearMigrations(): void {
|
|
this.migrations = [];
|
|
}
|
|
}
|
|
|
|
// Create a singleton instance of the migration registry
|
|
const migrationRegistry = new MigrationRegistry();
|
|
|
|
/**
|
|
* Register a migration with the migration service
|
|
*
|
|
* This function is used by the migration system to register database
|
|
* schema migrations that need to be applied to the database.
|
|
*
|
|
* @param migration - The migration to register
|
|
*/
|
|
export function registerMigration(migration: Migration): void {
|
|
migrationRegistry.registerMigration(migration);
|
|
}
|
|
|
|
/**
|
|
* Run all registered migrations against the database
|
|
*
|
|
* This function executes all registered migrations in order, checking
|
|
* which ones have already been applied to avoid duplicate execution.
|
|
* It creates a migrations table if it doesn't exist to track applied
|
|
* migrations.
|
|
*
|
|
* @param sqlExec - Function to execute SQL statements
|
|
* @param sqlQuery - Function to query SQL data
|
|
* @param extractMigrationNames - Function to extract migration names from query results
|
|
* @returns Promise that resolves when all migrations are complete
|
|
*/
|
|
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] Checking migration status...");
|
|
|
|
// Create migrations table if it doesn't exist
|
|
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");
|
|
|
|
// 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));
|
|
|
|
// 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;
|
|
|
|
// 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
|
|
console.log(`🔧 [Migration] Executing SQL for ${migration.name}...`);
|
|
await sqlExec(migration.sql);
|
|
console.log(`✅ [Migration] SQL executed successfully 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}). Marking as complete.`);
|
|
logger.warn(
|
|
`[MigrationService] Migration ${migration.name} appears to be already applied (${errorMessage}). Marking as complete.`,
|
|
);
|
|
|
|
// 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}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
|
|
|