#!/usr/bin/env php DatabaseMigrations) that are only resolvable via the // autoloader. Without this, run.php dies on a fresh database. // vendor/ is a sibling dir in dev (repo root) and same-dir in prod. $autoloadCandidates = [ dirname($root) . '/vendor/autoload.php', $root . '/vendor/autoload.php', ]; $autoloadPath = null; foreach ($autoloadCandidates as $candidate) { if (file_exists($candidate)) { $autoloadPath = $candidate; require_once $candidate; break; } } // PHP migrations run as isolated subprocesses, so they do not inherit the // autoloader above. Export the resolved path so run.php (and any code it // invokes) can inject it into those subprocesses. if ($autoloadPath !== null) { putenv('XAMXAM_AUTOLOAD=' . $autoloadPath); $_ENV['XAMXAM_AUTOLOAD'] = $autoloadPath; $_SERVER['XAMXAM_AUTOLOAD'] = $autoloadPath; } if (!file_exists($dbPath)) { die("Database not found: $dbPath\n"); } $pdo = new PDO('sqlite:' . $dbPath); $pdo->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. // The subprocess only requires the migration file, so composer's // autoloader has to be loaded there too. -d auto_prepend_file // injects it without altering the migration's own $argv contract // ($argv[1] must remain the DB path). $phpArgs = ''; if ($autoloadPath !== null) { $phpArgs = '-d auto_prepend_file=' . escapeshellarg($autoloadPath) . ' '; } $cmd = sprintf( 'php %s%s %s 2>&1', $phpArgs, 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";