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.
 
 
 
 
 
 

248 lines
8.4 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
await sqlExec(`
CREATE TABLE IF NOT EXISTS migrations (
name TEXT PRIMARY KEY,
applied_at TEXT DEFAULT CURRENT_TIMESTAMP
);
`);
// Get list of already applied migrations
const appliedMigrationsResult = await sqlQuery(
"SELECT name FROM migrations",
);
const appliedMigrations = extractMigrationNames(appliedMigrationsResult);
// 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`);
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 {
// 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();
// Check if it's a duplicate column error - this means the column already exists
if (
errorMessage.includes("duplicate column") ||
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.`,
);
// Mark the migration as applied since the schema change already exists
try {
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,
);
}
} 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;
}
}
/**
* 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<T>(
migration: Migration,
sqlExec: (sql: string, params?: unknown[]) => Promise<unknown>,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
): Promise<void> {
// 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);
}
}