Files
xamxam/app/migrations/run.php
Pontoporeia 021c58925e fix: auto-regenerate thesis identifier on any year-prefix mismatch, support .php migrations in runner
ThesisEditController::save() previously only regenerated the identifier when
the year field changed during an edit. If a thesis had its year corrected in
a past edit (or via other means) and the identifier still carried the old
year prefix, subsequent edits that didn't touch the year field would leave
the mismatched identifier in place.

Now saves() also checks whether the existing identifier's 4-digit prefix
matches the thesis year, and regenerates if not — regardless of whether year
changed in the current edit.

The migration runner (run.php) only scanned for .sql files, so PHP migrations
(013, 016, 018, 038) were never auto-applied. Extended the runner to also
discover and execute .php migrations in a subprocess. If a PHP migration fails
with an idempotent error (no such column, already exists, duplicate column),
the runner treats it as already-applied and continues rather than aborting
— preventing a stale migration like 016 (banner_path already dropped by 028)
from blocking migrations that come after it alphabetically (e.g. 038).

Updated migrations 016 and 038 to accept an optional $argv[1] DB path.
Fixed 016 to gracefully handle the banner_path column already being gone
(exit 0 instead of fatal).
2026-06-10 00:17:30 +02:00

129 lines
3.9 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');
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
$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";