migrate.php wrapped each migration file's statements in begin_transaction()/rollback(), but MySQL DDL statements cause an implicit commit — so a rollback couldn't actually undo earlier DDL already executed within the same file. A migration failing partway left the DB altered but unrecorded, and the next run retried the whole file from statement 1, hitting "already exists" errors not on the safe-to-ignore allowlist and permanently wedging the runner. Removed the transaction wrapper (it only gave false confidence) and added a migration_progress table that records the index of the last successfully-executed statement in each file. A re-run after a partial failure now resumes right after the last success instead of re-executing already-applied DDL. Verified against real MariaDB with a 4-statement migration where statement 3 fails: run 1 correctly applies statements 1-2 and records progress at index 1; after fixing the bad statement, run 2 resumes at statement 3, completes, and clears the progress marker. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
226 lines
7.4 KiB
PHP
226 lines
7.4 KiB
PHP
#!/usr/bin/env php
|
|
<?php
|
|
/**
|
|
* Database Migration Runner
|
|
*
|
|
* Runs SQL migration files in order. Tracks completed migrations
|
|
* to prevent re-running them.
|
|
*
|
|
* Usage:
|
|
* php migrate.php # Run all pending migrations
|
|
* php migrate.php --status # Show migration status
|
|
* php migrate.php --dry-run # Show what would be run without executing
|
|
*/
|
|
|
|
// Prevent web access
|
|
if (php_sapi_name() !== 'cli') {
|
|
http_response_code(403);
|
|
exit('CLI access only');
|
|
}
|
|
|
|
require_once dirname(__DIR__) . '/config/config.php';
|
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
|
|
|
$dryRun = in_array('--dry-run', $argv);
|
|
$statusOnly = in_array('--status', $argv);
|
|
|
|
echo "=== Database Migration Runner ===\n\n";
|
|
|
|
try {
|
|
$conn = Database::getConnection();
|
|
} catch (Exception $e) {
|
|
echo "Error: Could not connect to database: " . $e->getMessage() . "\n";
|
|
exit(1);
|
|
}
|
|
|
|
// Create migrations tracking table if it doesn't exist
|
|
$createTable = "CREATE TABLE IF NOT EXISTS migrations (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
filename VARCHAR(255) NOT NULL UNIQUE,
|
|
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
INDEX idx_filename (filename)
|
|
)";
|
|
|
|
if (!$conn->query($createTable)) {
|
|
echo "Error: Could not create migrations table: " . $conn->error . "\n";
|
|
exit(1);
|
|
}
|
|
|
|
// Tracks per-statement progress within a migration file. MySQL DDL statements
|
|
// (ALTER/CREATE TABLE, etc.) cause an implicit commit, so begin_transaction()/
|
|
// rollback() around a whole file can't actually undo DDL already executed
|
|
// earlier in that same file. This table lets a re-run after a partial failure
|
|
// resume from the statement after the last one that succeeded, instead of
|
|
// re-executing already-applied DDL and wedging on "already exists" errors.
|
|
$createProgressTable = "CREATE TABLE IF NOT EXISTS migration_progress (
|
|
filename VARCHAR(255) NOT NULL PRIMARY KEY,
|
|
last_statement_index INT NOT NULL,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
)";
|
|
|
|
if (!$conn->query($createProgressTable)) {
|
|
echo "Error: Could not create migration_progress table: " . $conn->error . "\n";
|
|
exit(1);
|
|
}
|
|
|
|
// Get list of completed migrations
|
|
$completed = [];
|
|
$result = $conn->query("SELECT filename FROM migrations ORDER BY id");
|
|
while ($row = $result->fetch_assoc()) {
|
|
$completed[] = $row['filename'];
|
|
}
|
|
|
|
// Get list of migration files
|
|
$migrationsDir = __DIR__;
|
|
$files = glob($migrationsDir . '/*.sql');
|
|
sort($files);
|
|
|
|
if (empty($files)) {
|
|
echo "No migration files found.\n";
|
|
exit(0);
|
|
}
|
|
|
|
if ($statusOnly) {
|
|
echo "Migration Status:\n";
|
|
echo str_repeat('-', 60) . "\n";
|
|
foreach ($files as $file) {
|
|
$filename = basename($file);
|
|
$status = in_array($filename, $completed) ? '[DONE]' : '[PENDING]';
|
|
echo sprintf(" %s %s\n", $status, $filename);
|
|
}
|
|
exit(0);
|
|
}
|
|
|
|
// Find pending migrations
|
|
$pending = [];
|
|
foreach ($files as $file) {
|
|
$filename = basename($file);
|
|
if (!in_array($filename, $completed)) {
|
|
$pending[] = $file;
|
|
}
|
|
}
|
|
|
|
if (empty($pending)) {
|
|
echo "All migrations are up to date.\n";
|
|
exit(0);
|
|
}
|
|
|
|
echo sprintf("Found %d pending migration(s):\n", count($pending));
|
|
foreach ($pending as $file) {
|
|
echo " - " . basename($file) . "\n";
|
|
}
|
|
echo "\n";
|
|
|
|
if ($dryRun) {
|
|
echo "[DRY RUN] No changes made.\n";
|
|
exit(0);
|
|
}
|
|
|
|
// Run pending migrations
|
|
$success = 0;
|
|
$failed = 0;
|
|
|
|
foreach ($pending as $file) {
|
|
$filename = basename($file);
|
|
echo "Running: $filename... ";
|
|
|
|
$sql = file_get_contents($file);
|
|
if ($sql === false) {
|
|
echo "FAILED (could not read file)\n";
|
|
$failed++;
|
|
continue;
|
|
}
|
|
|
|
// Execute migration statement-by-statement, tracking progress as we go.
|
|
// No begin_transaction()/rollback() here: DDL statements auto-commit in
|
|
// MySQL/MariaDB regardless, so a transaction wrapper around the whole
|
|
// file would only create the illusion of atomicity while giving no real
|
|
// protection. Instead, each statement commits immediately (autocommit),
|
|
// and its index is durably recorded so a later re-run can resume exactly
|
|
// where a previous run left off rather than re-executing already-applied
|
|
// DDL.
|
|
try {
|
|
// Split by semicolon but respect statements properly
|
|
// Note: This doesn't handle semicolons in strings, but our migrations are simple
|
|
$statements = array_values(array_filter(
|
|
array_map('trim', explode(';', $sql)),
|
|
function($stmt) {
|
|
// Remove comments and check if there's actual SQL
|
|
$cleaned = preg_replace('/--.*$/m', '', $stmt);
|
|
return !empty(trim($cleaned));
|
|
}
|
|
));
|
|
|
|
$resumeFrom = 0;
|
|
$progressStmt = $conn->prepare(
|
|
"SELECT last_statement_index FROM migration_progress WHERE filename = ?"
|
|
);
|
|
$progressStmt->bind_param('s', $filename);
|
|
$progressStmt->execute();
|
|
$progressRow = $progressStmt->get_result()->fetch_assoc();
|
|
$progressStmt->close();
|
|
if ($progressRow) {
|
|
$resumeFrom = (int)$progressRow['last_statement_index'] + 1;
|
|
echo "\n Resuming from statement " . ($resumeFrom + 1) . " of " . count($statements)
|
|
. " after a previous partial failure... ";
|
|
}
|
|
|
|
foreach ($statements as $index => $statement) {
|
|
if ($index < $resumeFrom) {
|
|
continue;
|
|
}
|
|
|
|
if (!$conn->query($statement)) {
|
|
// Some "errors" are acceptable (like "index already exists")
|
|
$error = $conn->error;
|
|
if (strpos($error, 'Duplicate key name') !== false ||
|
|
strpos($error, 'already exists') !== false) {
|
|
// Index already exists, that's fine
|
|
} else {
|
|
throw new Exception($error);
|
|
}
|
|
}
|
|
|
|
// Record progress after every statement so a later run can
|
|
// resume from here even if a subsequent statement fails.
|
|
$upsert = $conn->prepare(
|
|
"INSERT INTO migration_progress (filename, last_statement_index) VALUES (?, ?)
|
|
ON DUPLICATE KEY UPDATE last_statement_index = VALUES(last_statement_index)"
|
|
);
|
|
$upsert->bind_param('si', $filename, $index);
|
|
$upsert->execute();
|
|
$upsert->close();
|
|
}
|
|
|
|
// Record the migration as fully complete and clear its progress marker
|
|
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
|
|
$stmt->bind_param('s', $filename);
|
|
if (!$stmt->execute()) {
|
|
throw new Exception("Could not record migration: " . $conn->error);
|
|
}
|
|
|
|
$clearProgress = $conn->prepare("DELETE FROM migration_progress WHERE filename = ?");
|
|
$clearProgress->bind_param('s', $filename);
|
|
$clearProgress->execute();
|
|
$clearProgress->close();
|
|
|
|
echo "OK\n";
|
|
$success++;
|
|
|
|
} catch (Exception $e) {
|
|
// Nothing to roll back: every statement up to the failure already
|
|
// committed (DDL implicitly, everything else via autocommit). The
|
|
// progress marker recorded above reflects exactly how far this file
|
|
// got, so the next run will resume right after the last success.
|
|
echo "FAILED (" . $e->getMessage() . ")\n";
|
|
$failed++;
|
|
}
|
|
}
|
|
|
|
echo "\n";
|
|
echo "=== Migration Complete ===\n";
|
|
echo sprintf(" Success: %d\n", $success);
|
|
echo sprintf(" Failed: %d\n", $failed);
|
|
|
|
exit($failed > 0 ? 1 : 0);
|