mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
run.php never loaded vendor/autoload.php. SQL migrations were fine, but PHP migrations run as isolated subprocesses that require src/ files directly, and those files reference sibling classes (e.g. Database -> DatabaseMigrations) resolvable only via composer's classmap. On a fresh schema.sql database the run died at 013_fix_remarks_keywords.php with: Class "DatabaseMigrations" not found in app/src/Database.php:89 - run.php now resolves and requires the autoloader (vendor/ is a sibling dir in dev, same-dir in prod, matching app/bootstrap.php). - Subprocess PHP migrations get it injected via -d auto_prepend_file so each migration's own contract is untouched ($argv[1] stays the DB path). Verified: fresh schema.sql DB applies 47 migrations, exit 0; second run is a no-op (0 applied).
165 lines
5.3 KiB
PHP
165 lines
5.3 KiB
PHP
#!/usr/bin/env php
|
|
<?php
|
|
/**
|
|
* Run pending migrations on the production database.
|
|
*
|
|
* Usage: php app/migrations/run.php [DB_PATH]
|
|
*
|
|
* If no DB_PATH is given, defaults to storage/xamxam.db.
|
|
*
|
|
* Scans both migrations/pending/ and migrations/applied/ for .sql files.
|
|
* Each is applied in alphabetical order if not already tracked in _migrations.
|
|
*/
|
|
|
|
$root = dirname(__DIR__);
|
|
$dbPath = $argv[1] ?? ($root . '/storage/xamxam.db');
|
|
|
|
// Load the composer autoloader (classmap over src/). PHP migrations require
|
|
// src/ files directly, and those files reference sibling classes
|
|
// (e.g. Database -> 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";
|