/** * @ai-summary Orchestrates all feature migrations in dependency order */ import { Pool } from 'pg'; import { readFileSync, readdirSync } from 'fs'; import { join, resolve } from 'path'; import { appConfig } from '../../core/config/config-loader'; const pool = new Pool({ connectionString: appConfig.getDatabaseUrl(), }); // Define migration order based on dependencies and packaging layout // We package migrations under /app/migrations with two roots: features/ and core/ // The update_updated_at_column() function is defined in features/vehicles first, // and user-preferences trigger depends on it; so run vehicles before core/user-preferences. const MIGRATION_ORDER = [ 'features/vehicles', // Primary entity, defines update_updated_at_column() 'features/platform', // Normalized make/model/trim schema for dropdowns 'features/documents', // Depends on vehicles; provides documents table 'core/user-preferences', // Depends on update_updated_at_column() 'features/fuel-logs', // Depends on vehicles 'features/maintenance', // Depends on vehicles 'features/stations', // Independent 'features/admin', // Admin role management and oversight; depends on update_updated_at_column() 'features/backup', // Admin backup feature; depends on update_updated_at_column() 'features/notifications', // Depends on maintenance and documents 'features/user-profile', // User profile management; independent 'features/terms-agreement', // Terms & Conditions acceptance audit trail 'features/audit-log', // Centralized audit logging; independent ]; // Base directory where migrations are copied inside the image (set by Dockerfile) const MIGRATIONS_DIR = resolve(process.env['MIGRATIONS_DIR'] || join(__dirname, '../../../migrations')); async function getExecutedMigrations(): Promise>> { const executed: Record> = {}; // Ensure tracking table exists (retry across transient DB restarts) const retry = async (op: () => Promise, timeoutMs = 60000): Promise => { const start = Date.now(); while (true) { try { return await op(); } catch (e) { if (Date.now() - start > timeoutMs) throw e; await new Promise(res => setTimeout(res, 2000)); } } }; await retry(() => pool.query(` CREATE TABLE IF NOT EXISTS _migrations ( id SERIAL PRIMARY KEY, feature VARCHAR(100) NOT NULL, file VARCHAR(255) NOT NULL, executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(feature, file) ); `)); const res = await retry(() => pool.query('SELECT feature, file FROM _migrations')); for (const row of res.rows) { if (!executed[row.feature]) executed[row.feature] = new Set(); executed[row.feature].add(row.file); } return executed; } async function runFeatureMigrations(featureName: string) { const migrationDir = join(MIGRATIONS_DIR, featureName, 'migrations'); try { // Guard per-feature in case DB becomes available slightly later on cold start const ping = async (timeoutMs = 60000) => { const start = Date.now(); while (true) { try { await pool.query('SELECT 1'); return; } catch (e) { if (Date.now() - start > timeoutMs) throw e; await new Promise(r => setTimeout(r, 2000)); } } }; await ping(); const files = readdirSync(migrationDir) .filter(f => f.endsWith('.sql')) .sort(); const executed = await getExecutedMigrations(); const already = executed[featureName] || new Set(); for (const file of files) { if (already.has(file)) { console.log(`↷ Skipping already executed migration: ${featureName}/${file}`); continue; } const sql = readFileSync(join(migrationDir, file), 'utf-8'); console.log(`Running migration: ${featureName}/${file}`); await pool.query(sql); await pool.query('INSERT INTO _migrations(feature, file) VALUES ($1, $2) ON CONFLICT DO NOTHING', [featureName, file]); console.log(`āœ… Completed: ${featureName}/${file}`); } } catch (error) { console.error(`āŒ Failed migration for ${featureName}:`, error); throw error; } } async function main() { try { console.log('Starting migration orchestration...'); console.log(`Using migrations directory: ${MIGRATIONS_DIR}`); // Wait for database to be reachable (handles cold starts) const waitForDb = async (timeoutMs = 60000) => { const start = Date.now(); while (true) { try { await pool.query('SELECT 1'); return; } catch (e) { if (Date.now() - start > timeoutMs) throw e; await new Promise(res => setTimeout(res, 2000)); } } }; await waitForDb(); // Run migrations in order for (const feature of MIGRATION_ORDER) { console.log(`\nMigrating feature: ${feature}`); await runFeatureMigrations(feature); } console.log('\nāœ… All migrations completed successfully'); } catch (error) { console.error('Migration failed:', error); process.exit(1); } finally { await pool.end(); } } if (require.main === module) { main(); }