#!/usr/bin/env tsx /** * Database Migration Runner * Automatically detects and executes unapplied SQL migrations * * Usage: * npm run db:migrate # Run on local database * npm run db:migrate:remote # Run on remote database * npm run db:migrate:status # Show migration status */ import { spawnSync } from 'child_process'; import { readdirSync, readFileSync, existsSync } from 'fs'; import { join, basename, resolve } from 'path'; // ============================================================ // Configuration // ============================================================ const DB_NAME = 'cloud-instances-db'; const MIGRATIONS_DIR = join(process.cwd(), 'migrations'); const MIGRATION_HISTORY_TABLE = 'migration_history'; type Environment = 'local' | 'remote'; type Command = 'migrate' | 'status'; // ============================================================ // Utility Functions // ============================================================ /** * Sanitize migration name (prevent SQL injection) * Only allows alphanumeric characters, underscores, and hyphens */ function sanitizeMigrationName(name: string): string { if (!/^[a-zA-Z0-9_-]+$/.test(name)) { throw new Error(`Invalid migration name: ${name} (only alphanumeric, underscore, and hyphen allowed)`); } return name; } /** * Validate file path (prevent path traversal) * Ensures the file is within the migrations directory */ function validateFilePath(filename: string): string { // Check filename format if (!/^[a-zA-Z0-9_-]+\.sql$/.test(filename)) { throw new Error(`Invalid filename format: ${filename}`); } const filePath = join(MIGRATIONS_DIR, filename); const resolvedPath = resolve(filePath); const resolvedMigrationsDir = resolve(MIGRATIONS_DIR); // Ensure resolved path is within migrations directory if (!resolvedPath.startsWith(resolvedMigrationsDir + '/') && resolvedPath !== resolvedMigrationsDir) { throw new Error(`Path traversal detected: ${filename}`); } return filePath; } /** * Execute wrangler d1 command and return output * Uses spawnSync to prevent command injection */ function executeD1Command(sql: string, env: Environment): string { const envFlag = env === 'remote' ? '--remote' : '--local'; const args = ['d1', 'execute', DB_NAME, envFlag, '--command', sql]; try { const result = spawnSync('wrangler', args, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); if (result.error) { throw new Error(`Failed to execute wrangler: ${result.error.message}`); } if (result.status !== 0) { throw new Error(`D1 command failed with exit code ${result.status}: ${result.stderr}`); } return result.stdout; } catch (error: any) { throw new Error(`D1 command failed: ${error.message}`); } } /** * Execute SQL file using wrangler d1 * Uses spawnSync to prevent command injection */ function executeD1File(filePath: string, env: Environment): void { const envFlag = env === 'remote' ? '--remote' : '--local'; const args = ['d1', 'execute', DB_NAME, envFlag, '--file', filePath]; try { const result = spawnSync('wrangler', args, { encoding: 'utf-8', stdio: 'inherit' }); if (result.error) { throw new Error(`Failed to execute wrangler: ${result.error.message}`); } if (result.status !== 0) { throw new Error(`Migration file execution failed with exit code ${result.status}`); } } catch (error: any) { throw new Error(`Migration file execution failed: ${error.message}`); } } /** * Get list of applied migrations from database */ function getAppliedMigrations(env: Environment): Set { try { const sql = `SELECT migration_name FROM ${MIGRATION_HISTORY_TABLE} WHERE success = 1`; const output = executeD1Command(sql, env); const migrations = new Set(); const lines = output.split('\n'); for (const line of lines) { const trimmed = line.trim(); if (trimmed && !trimmed.includes('migration_name') && !trimmed.includes('─')) { migrations.add(trimmed); } } return migrations; } catch (error: any) { // If table doesn't exist, return empty set if (error.message.includes('no such table')) { return new Set(); } throw error; } } /** * Get list of migration files from migrations directory */ function getMigrationFiles(): string[] { if (!existsSync(MIGRATIONS_DIR)) { throw new Error(`Migrations directory not found: ${MIGRATIONS_DIR}`); } const files = readdirSync(MIGRATIONS_DIR) .filter(f => f.endsWith('.sql')) .filter(f => f !== 'README.md') .sort(); // Alphabetical sort ensures numeric order (000, 002, 003, 004) return files; } /** * Extract migration name from filename (without .sql extension) */ function getMigrationName(filename: string): string { return basename(filename, '.sql'); } /** * Record migration execution in history table */ function recordMigration( migrationName: string, executionTimeMs: number, success: boolean, errorMessage: string | null, env: Environment ): void { // Sanitize migration name to prevent SQL injection const safeMigrationName = sanitizeMigrationName(migrationName); // Escape error message (if any) to prevent SQL injection const safeErrorMessage = errorMessage ? `'${errorMessage.replace(/'/g, "''")}'` : 'NULL'; const sql = ` INSERT INTO ${MIGRATION_HISTORY_TABLE} (migration_name, execution_time_ms, success, error_message) VALUES ('${safeMigrationName}', ${executionTimeMs}, ${success ? 1 : 0}, ${safeErrorMessage}) `; try { executeD1Command(sql, env); } catch (error: any) { console.error(`[ERROR] Failed to record migration: ${error.message}`); } } /** * Execute a single migration file */ function executeMigration(filename: string, env: Environment): boolean { const migrationName = getMigrationName(filename); // Validate file path to prevent path traversal attacks const filePath = validateFilePath(filename); console.log(`\n[Migrate] Executing: ${migrationName}`); console.log(`[Migrate] File: ${filename}`); const startTime = Date.now(); try { executeD1File(filePath, env); const executionTime = Date.now() - startTime; console.log(`[Migrate] ✅ Success (${executionTime}ms)`); // Record success (only if not 000_migration_history itself) if (migrationName !== '000_migration_history') { recordMigration(migrationName, executionTime, true, null, env); } return true; } catch (error: any) { const executionTime = Date.now() - startTime; const errorMessage = error.message || 'Unknown error'; console.error(`[Migrate] ❌ Failed (${executionTime}ms)`); console.error(`[Migrate] Error: ${errorMessage}`); // Record failure (only if not 000_migration_history itself) if (migrationName !== '000_migration_history') { recordMigration(migrationName, executionTime, false, errorMessage, env); } return false; } } // ============================================================ // Main Migration Logic // ============================================================ /** * Run all pending migrations */ function runMigrations(env: Environment): void { console.log(`\n${'='.repeat(60)}`); console.log(`[Migrate] Database Migration Runner`); console.log(`[Migrate] Environment: ${env}`); console.log(`[Migrate] Database: ${DB_NAME}`); console.log(`${'='.repeat(60)}\n`); const allMigrations = getMigrationFiles(); console.log(`[Migrate] Found ${allMigrations.length} migration files`); // Ensure migration history table exists first const historyMigration = allMigrations.find(f => f.startsWith('000_migration_history')); if (!historyMigration) { console.error('[Migrate] ❌ Migration history table file (000_migration_history.sql) not found!'); process.exit(1); } // Execute history table migration first console.log(`[Migrate] Ensuring migration tracking table exists...`); if (!executeMigration(historyMigration, env)) { console.error('[Migrate] ❌ Failed to create migration history table'); process.exit(1); } // Get applied migrations const appliedMigrations = getAppliedMigrations(env); console.log(`[Migrate] ${appliedMigrations.size} migrations already applied`); // Filter pending migrations (excluding 000_migration_history) const pendingMigrations = allMigrations .filter(f => !f.startsWith('000_migration_history')) .filter(f => !appliedMigrations.has(getMigrationName(f))); if (pendingMigrations.length === 0) { console.log('\n[Migrate] ✅ All migrations are up to date. Nothing to do.'); return; } console.log(`\n[Migrate] ${pendingMigrations.length} pending migrations to execute:`); pendingMigrations.forEach(f => console.log(` - ${getMigrationName(f)}`)); // Execute each pending migration let successCount = 0; let failureCount = 0; for (const filename of pendingMigrations) { const success = executeMigration(filename, env); if (success) { successCount++; } else { failureCount++; console.error(`\n[Migrate] ❌ Migration failed: ${filename}`); console.error(`[Migrate] Stopping migration process.`); break; // Stop on first failure } } // Summary console.log(`\n${'='.repeat(60)}`); console.log(`[Migrate] Migration Summary:`); console.log(`[Migrate] ✅ Successful: ${successCount}`); if (failureCount > 0) { console.log(`[Migrate] ❌ Failed: ${failureCount}`); } console.log(`${'='.repeat(60)}\n`); if (failureCount > 0) { process.exit(1); } } /** * Show migration status */ function showStatus(env: Environment): void { console.log(`\n${'='.repeat(60)}`); console.log(`[Migrate] Migration Status`); console.log(`[Migrate] Environment: ${env}`); console.log(`[Migrate] Database: ${DB_NAME}`); console.log(`${'='.repeat(60)}\n`); const allMigrations = getMigrationFiles(); console.log(`[Migrate] Total migration files: ${allMigrations.length}`); try { const appliedMigrations = getAppliedMigrations(env); console.log(`[Migrate] Applied migrations: ${appliedMigrations.size}`); // Show detailed status console.log(`\n[Migrate] Detailed Status:\n`); for (const filename of allMigrations) { const migrationName = getMigrationName(filename); const isApplied = appliedMigrations.has(migrationName); const status = isApplied ? '✅' : '⏳'; const label = isApplied ? 'Applied' : 'Pending'; console.log(` ${status} ${migrationName.padEnd(40)} [${label}]`); } const pendingCount = allMigrations.length - appliedMigrations.size; if (pendingCount > 0) { console.log(`\n[Migrate] ⚠️ ${pendingCount} migrations pending execution`); console.log(`[Migrate] Run 'npm run db:migrate' to apply pending migrations`); } else { console.log(`\n[Migrate] ✅ All migrations are up to date`); } } catch (error: any) { console.error(`[Migrate] ❌ Error reading migration status: ${error.message}`); console.error(`[Migrate] The migration_history table may not exist yet.`); console.error(`[Migrate] Run 'npm run db:migrate' to initialize the system.`); process.exit(1); } console.log(`\n${'='.repeat(60)}\n`); } // ============================================================ // CLI Entry Point // ============================================================ function main(): void { const args = process.argv.slice(2); // Parse command let command: Command = 'migrate'; if (args.includes('--status')) { command = 'status'; } // Parse environment let env: Environment = 'local'; if (args.includes('--remote')) { env = 'remote'; } // Execute command try { if (command === 'status') { showStatus(env); } else { runMigrations(env); } } catch (error: any) { console.error(`\n[Migrate] ❌ Fatal error: ${error.message}`); process.exit(1); } } // Run if executed directly if (require.main === module) { main(); }