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