#!/usr/bin/env php setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // Create migrations tracking table $pdo->exec(" CREATE TABLE IF NOT EXISTS _migrations ( name TEXT PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP ) "); $applied = $pdo->query("SELECT name FROM _migrations")->fetchAll(PDO::FETCH_COLUMN); $pendingDir = $root . '/migrations/pending'; $appliedDir = $root . '/migrations/applied'; if (!is_dir($appliedDir)) { mkdir($appliedDir, 0755, true); } // Collect .sql and .php files from both pending and applied dirs $files = []; foreach ([$pendingDir, $appliedDir] as $dir) { if (!is_dir($dir)) continue; foreach (glob($dir . '/*.{sql,php}', GLOB_BRACE) as $f) { $files[basename($f)] = $f; } } ksort($files); if (empty($files)) { echo "No pending migration files.\n"; exit(0); } $count = 0; foreach ($files as $name => $file) { if (in_array($name, $applied, true)) { echo "Skip (already applied): $name\n"; continue; } $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); echo "Applying: $name\n"; $isPhp = $ext === 'php'; try { if ($isPhp) { // PHP migrations: execute in a subprocess for isolation $cmd = sprintf( 'php %s %s 2>&1', escapeshellarg($file), escapeshellarg($dbPath) ); exec($cmd, $output, $exitCode); $outputStr = implode("\n", $output); echo $outputStr . "\n"; if ($exitCode !== 0) { // Check output for idempotent errors before treating as fatal $skipPatterns = [ 'no such column', 'duplicate column name', 'already exists', ]; $shouldSkip = false; foreach ($skipPatterns as $pat) { if (stripos($outputStr, $pat) !== false) { $shouldSkip = true; break; } } if ($shouldSkip) { echo " Skipping (already applied)\n"; // Mark as applied so we don't re-attempt $pdo->prepare("INSERT OR REPLACE INTO _migrations (name) VALUES (?)")->execute([$name]); $count++; continue; } throw new RuntimeException("PHP migration exited with code $exitCode"); } } else { // SQL migrations: execute inline $sql = file_get_contents($file); $pdo->exec($sql); } } catch (\Throwable $e) { $msg = $e->getMessage(); // Ignore idempotent errors (column/trigger/index already exists or already removed) if (stripos($msg, 'duplicate column name') !== false || stripos($msg, 'already exists') !== false || stripos($msg, 'no such column') !== false) { echo " Skipping (already applied)\n"; } else { echo " FAILED: $msg\n"; throw $e; } } $pdo->prepare("INSERT OR REPLACE INTO _migrations (name) VALUES (?)")->execute([$name]); if (str_starts_with($file, $pendingDir)) { rename($file, $appliedDir . '/' . $name); } $count++; } echo "$count migration(s) applied.\n";