266 lines
7.9 KiB
JavaScript
266 lines
7.9 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const database = require('../config/database');
|
|
|
|
const migrationsDir = path.join(__dirname);
|
|
|
|
/**
|
|
* Enterprise-grade migration runner with proper state tracking
|
|
*/
|
|
class MigrationRunner {
|
|
constructor() {
|
|
this.processId = `migration_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
/**
|
|
* Calculate SHA-256 checksum of migration content
|
|
*/
|
|
calculateChecksum(content) {
|
|
return crypto.createHash('sha256').update(content).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* Parse migration version from filename
|
|
*/
|
|
parseVersion(filename) {
|
|
const match = filename.match(/^(\d{3})_/);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
/**
|
|
* Check if migration tracking system exists
|
|
*/
|
|
async ensureMigrationTrackingExists() {
|
|
try {
|
|
const result = await database.query(`
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM information_schema.tables
|
|
WHERE table_name = 'schema_migrations'
|
|
AND table_schema = 'public'
|
|
) as exists
|
|
`);
|
|
|
|
return result.rows[0].exists;
|
|
} catch (error) {
|
|
console.error('Error checking migration tracking:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize migration tracking system
|
|
*/
|
|
async initializeMigrationTracking() {
|
|
console.log('🔧 Initializing migration tracking system...');
|
|
|
|
const trackingMigrationPath = path.join(migrationsDir, '000_migration_tracking_system.sql');
|
|
if (!fs.existsSync(trackingMigrationPath)) {
|
|
throw new Error('Migration tracking system file not found: 000_migration_tracking_system.sql');
|
|
}
|
|
|
|
const trackingSQL = fs.readFileSync(trackingMigrationPath, 'utf8');
|
|
await database.query(trackingSQL);
|
|
console.log('✅ Migration tracking system initialized');
|
|
}
|
|
|
|
/**
|
|
* Acquire migration lock to prevent concurrent runs
|
|
*/
|
|
async acquireLock() {
|
|
console.log(`🔒 Acquiring migration lock (${this.processId})...`);
|
|
|
|
const result = await database.query(
|
|
'SELECT acquire_migration_lock($1) as acquired',
|
|
[this.processId]
|
|
);
|
|
|
|
if (!result.rows[0].acquired) {
|
|
throw new Error('Could not acquire migration lock. Another migration may be running.');
|
|
}
|
|
|
|
console.log('✅ Migration lock acquired');
|
|
}
|
|
|
|
/**
|
|
* Release migration lock
|
|
*/
|
|
async releaseLock() {
|
|
try {
|
|
await database.query('SELECT release_migration_lock($1)', [this.processId]);
|
|
console.log('🔓 Migration lock released');
|
|
} catch (error) {
|
|
console.warn('⚠️ Error releasing migration lock:', error.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if migration has already been applied
|
|
*/
|
|
async isMigrationApplied(version) {
|
|
const result = await database.query(
|
|
'SELECT migration_applied($1) as applied',
|
|
[version]
|
|
);
|
|
return result.rows[0].applied;
|
|
}
|
|
|
|
/**
|
|
* Record migration execution
|
|
*/
|
|
async recordMigration(version, filename, checksum, executionTime, success, errorMessage = null) {
|
|
await database.query(
|
|
'SELECT record_migration($1, $2, $3, $4, $5, $6)',
|
|
[version, filename, checksum, executionTime, success, errorMessage]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get list of migration files to run
|
|
*/
|
|
getMigrationFiles() {
|
|
return fs.readdirSync(migrationsDir)
|
|
.filter(file => file.endsWith('.sql') && file !== '000_migration_tracking_system.sql')
|
|
.sort();
|
|
}
|
|
|
|
/**
|
|
* Run a single migration
|
|
*/
|
|
async runSingleMigration(migrationFile) {
|
|
const version = this.parseVersion(migrationFile);
|
|
if (!version) {
|
|
console.warn(`⚠️ Skipping file with invalid version format: ${migrationFile}`);
|
|
return;
|
|
}
|
|
|
|
// Check if already applied
|
|
if (await this.isMigrationApplied(version)) {
|
|
console.log(`⏭️ Skipping already applied migration: ${migrationFile}`);
|
|
return;
|
|
}
|
|
|
|
console.log(`🚀 Running migration: ${migrationFile}`);
|
|
|
|
const migrationPath = path.join(migrationsDir, migrationFile);
|
|
const migrationSQL = fs.readFileSync(migrationPath, 'utf8');
|
|
const checksum = this.calculateChecksum(migrationSQL);
|
|
|
|
const startTime = Date.now();
|
|
let success = false;
|
|
let errorMessage = null;
|
|
|
|
try {
|
|
await database.query(migrationSQL);
|
|
success = true;
|
|
console.log(`✅ Migration ${migrationFile} completed successfully!`);
|
|
} catch (err) {
|
|
errorMessage = err.message;
|
|
console.error(`❌ Migration ${migrationFile} failed:`, err.message);
|
|
|
|
// Check if it's an idempotent error we can ignore
|
|
const isIdempotentError = this.isIdempotentError(err);
|
|
if (isIdempotentError) {
|
|
console.warn(`⚠️ Treating as idempotent error, marking as successful`);
|
|
success = true;
|
|
errorMessage = `Idempotent: ${err.message}`;
|
|
} else {
|
|
throw err; // Re-throw non-idempotent errors
|
|
}
|
|
} finally {
|
|
const executionTime = Date.now() - startTime;
|
|
await this.recordMigration(version, migrationFile, checksum, executionTime, success, errorMessage);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if error is idempotent (safe to ignore)
|
|
*/
|
|
isIdempotentError(err) {
|
|
const message = (err && err.message) ? err.message.toLowerCase() : '';
|
|
const code = err && err.code ? err.code : '';
|
|
|
|
return message.includes('already exists') ||
|
|
code === '42710' /* duplicate_object */ ||
|
|
code === '42P07' /* duplicate_table */ ||
|
|
code === '42701' /* duplicate_column */ ||
|
|
code === '42P06' /* duplicate_schema */ ||
|
|
code === '42723' /* duplicate_function */;
|
|
}
|
|
|
|
/**
|
|
* Display migration status
|
|
*/
|
|
async displayStatus() {
|
|
try {
|
|
const result = await database.query('SELECT * FROM get_migration_history() LIMIT 10');
|
|
console.log('\n📊 Recent Migration History:');
|
|
console.log('Version | Filename | Applied At | Success | Time (ms)');
|
|
console.log('--------|----------|------------|---------|----------');
|
|
|
|
result.rows.forEach(row => {
|
|
const status = row.success ? '✅' : '❌';
|
|
const time = row.execution_time_ms || 'N/A';
|
|
console.log(`${row.version.padEnd(7)} | ${row.filename.substring(0, 30).padEnd(30)} | ${row.applied_at.toISOString().substring(0, 19)} | ${status.padEnd(7)} | ${time}`);
|
|
});
|
|
|
|
const versionResult = await database.query('SELECT get_current_schema_version() as version');
|
|
console.log(`\n🏷️ Current Schema Version: ${versionResult.rows[0].version || 'None'}`);
|
|
} catch (error) {
|
|
console.warn('⚠️ Could not display migration status:', error.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main migration runner
|
|
*/
|
|
async runMigrations() {
|
|
console.log('🚀 Starting Enterprise Database Migration System...');
|
|
|
|
try {
|
|
// Connect to database
|
|
await database.testConnection();
|
|
console.log('✅ Database connected successfully');
|
|
|
|
// Ensure migration tracking exists
|
|
const trackingExists = await this.ensureMigrationTrackingExists();
|
|
if (!trackingExists) {
|
|
await this.initializeMigrationTracking();
|
|
}
|
|
|
|
// Acquire lock
|
|
await this.acquireLock();
|
|
|
|
// Get migration files
|
|
const migrationFiles = this.getMigrationFiles();
|
|
console.log(`📄 Found ${migrationFiles.length} migration files to process`);
|
|
|
|
// Run migrations
|
|
for (const migrationFile of migrationFiles) {
|
|
await this.runSingleMigration(migrationFile);
|
|
}
|
|
|
|
// Display status
|
|
await this.displayStatus();
|
|
|
|
console.log('🎉 All migrations completed successfully!');
|
|
|
|
} catch (error) {
|
|
console.error('❌ Migration failed:', error);
|
|
process.exit(1);
|
|
} finally {
|
|
await this.releaseLock();
|
|
await database.close();
|
|
console.log('🔌 Database connection closed');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run migrations if this file is executed directly
|
|
if (require.main === module) {
|
|
const runner = new MigrationRunner();
|
|
runner.runMigrations();
|
|
}
|
|
|
|
module.exports = { MigrationRunner };
|