diff --git a/app/core/MigrationRunner.php b/app/core/MigrationRunner.php new file mode 100644 index 0000000..a577655 --- /dev/null +++ b/app/core/MigrationRunner.php @@ -0,0 +1,94 @@ +db = $db; + $this->migrationsDir = rtrim($migrationsDir, '/'); + if (!is_dir($this->migrationsDir)) { + throw new Exception("Migrations directory not found: {$this->migrationsDir}"); + } + $this->ensureMigrationsTable(); + } + + private function ensureMigrationsTable(): void + { + $sql = "CREATE TABLE IF NOT EXISTS migrations ( + id INT AUTO_INCREMENT PRIMARY KEY, + migration VARCHAR(255) NOT NULL UNIQUE, + applied_at DATETIME NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"; + $this->db->exec($sql); + } + + public function listAllMigrations(): array + { + $files = glob($this->migrationsDir . '/*.sql'); + sort($files, SORT_NATURAL); + return array_map('basename', $files); + } + + public function listAppliedMigrations(): array + { + $stmt = $this->db->query('SELECT migration FROM migrations ORDER BY migration ASC'); + return $stmt->fetchAll(PDO::FETCH_COLUMN) ?: []; + } + + public function listPendingMigrations(): array + { + $all = $this->listAllMigrations(); + $applied = $this->listAppliedMigrations(); + return array_values(array_diff($all, $applied)); + } + + public function hasPendingMigrations(): bool + { + return count($this->listPendingMigrations()) > 0; + } + + public function applyPendingMigrations(): array + { + $pending = $this->listPendingMigrations(); + $appliedNow = []; + if (empty($pending)) { + return $appliedNow; + } + + try { + $this->db->beginTransaction(); + foreach ($pending as $migration) { + $path = $this->migrationsDir . '/' . $migration; + $sql = file_get_contents($path); + if ($sql === false) { + throw new Exception("Unable to read migration file: {$migration}"); + } + // Split on ; at line ends, but allow inside procedures? Keep simple for our use-cases + $statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql))); + foreach ($statements as $stmtSql) { + if ($stmtSql === '') continue; + $this->db->exec($stmtSql); + } + $ins = $this->db->prepare('INSERT INTO migrations (migration, applied_at) VALUES (:m, NOW())'); + $ins->execute([':m' => $migration]); + $appliedNow[] = $migration; + } + $this->db->commit(); + } catch (Exception $e) { + if ($this->db->inTransaction()) { + $this->db->rollBack(); + } + throw $e; + } + + return $appliedNow; + } +} diff --git a/doc/database/README.md b/doc/database/README.md new file mode 100644 index 0000000..87e0a24 --- /dev/null +++ b/doc/database/README.md @@ -0,0 +1,63 @@ +# Database migrations + +This app ships with a lightweight SQL migration system to safely upgrade a running site when code changes require database changes. + +## Concepts + +- Migrations live in `doc/database/migrations/` and are plain `.sql` files. +- They are named in a sortable order, e.g. `YYYYMMDD_HHMMSS_description.sql`. +- Applied migrations are tracked in a DB table called `migrations`. +- Use the CLI script `scripts/migrate.php` to inspect and apply migrations. + +## Usage + +1. Show current status + +```bash +php scripts/migrate.php status +``` + +2. Apply all pending migrations + +```bash +php scripts/migrate.php up +``` + +3. Typical deployment steps + +- Pull new code from git. +- Put the site in maintenance mode (optional, recommended for sensitive changes). +- Run `php scripts/migrate.php status`. +- If there are pending migrations, run `php scripts/migrate.php up`. +- Clear opcache if applicable and resume traffic. + +## Authoring new migrations + +1. Create a new SQL file in `doc/database/migrations/`, e.g.: + +``` +doc/database/migrations/20250924_170001_add_user_meta_theme.sql +``` + +2. Write forward-only SQL. Avoid destructive changes unless absolutely necessary. + +3. Prefer idempotent SQL. For MySQL 8.0+ you can use `ADD COLUMN IF NOT EXISTS`. For older MySQL/MariaDB versions, either: + +- Check existence in PHP and conditionally run DDL, or +- Write migrations that are safe to run once and tracked by the `migrations` table. + +## Notes + +- The application checks for pending migrations at runtime and shows a warning banner but will not auto-apply changes. +- The `migrations` table is created automatically by the runner if missing. +- The runner executes each migration inside a single transaction (when supported by the storage engine for the statements used). If any statement fails, the migration batch is rolled back and no migration is marked as applied. + +## Example migration + +This repo includes an example migration that adds a per-user theme column: + +``` +20250924_170001_add_user_meta_theme.sql +``` + +It adds `user_meta.theme` used to store each user's preferred theme. diff --git a/doc/database/migrations/20250924_170001_add_user_meta_theme.sql b/doc/database/migrations/20250924_170001_add_user_meta_theme.sql new file mode 100644 index 0000000..762afed --- /dev/null +++ b/doc/database/migrations/20250924_170001_add_user_meta_theme.sql @@ -0,0 +1,3 @@ +-- Add user theme preference column to user_meta, if it doesn't exist +ALTER TABLE user_meta + ADD COLUMN IF NOT EXISTS theme VARCHAR(64) NULL DEFAULT NULL AFTER timezone; diff --git a/public_html/index.php b/public_html/index.php index e56faa9..771c7bf 100644 --- a/public_html/index.php +++ b/public_html/index.php @@ -183,6 +183,25 @@ if (isset($GLOBALS['user_IP'])) { $user_IP = $GLOBALS['user_IP']; } +// Check for pending DB migrations (non-intrusive: warn only) +try { + $migrationsDir = __DIR__ . '/../doc/database/migrations'; + if (is_dir($migrationsDir)) { + require_once __DIR__ . '/../app/core/MigrationRunner.php'; + $runner = new \App\Core\MigrationRunner($db, $migrationsDir); + if ($runner->hasPendingMigrations()) { + $pending = $runner->listPendingMigrations(); + $msg = 'Database schema is out of date. Pending migrations: ' . implode(', ', $pending) . '. Run: php scripts/migrate.php up'; + // Log and show as a system message + $logObject->log('warning', $msg, ['scope' => 'system']); + Feedback::flash('DB', 'MIGRATIONS_PENDING', $msg, false, true); + } + } +} catch (\Throwable $e) { + // Do not break the app; log only + error_log('Migration check failed: ' . $e->getMessage()); +} + // CSRF middleware and run pipeline $pipeline->add(function() { // Initialize security middleware diff --git a/scripts/migrate.php b/scripts/migrate.php new file mode 100644 index 0000000..34ee9b9 --- /dev/null +++ b/scripts/migrate.php @@ -0,0 +1,74 @@ +#!/usr/bin/env php +listAllMigrations(); + $applied = $runner->listAppliedMigrations(); + $pending = $runner->listPendingMigrations(); + + echo "All migrations (" . count($all) . "):\n"; + foreach ($all as $m) echo " - $m\n"; + echo "\nApplied (" . count($applied) . "):\n"; + foreach ($applied as $m) echo " - $m\n"; + echo "\nPending (" . count($pending) . "):\n"; + foreach ($pending as $m) echo " - $m\n"; + echo "\n"; + exit(0); + } elseif ($action === 'up') { + $pending = $runner->listPendingMigrations(); + if (empty($pending)) { + echo "No pending migrations.\n"; + exit(0); + } + echo "Applying " . count($pending) . " migration(s):\n"; + foreach ($pending as $m) echo " - $m\n"; + $applied = $runner->applyPendingMigrations(); + echo "\nApplied successfully: " . count($applied) . "\n"; + exit(0); + } else { + printUsage(); + exit(1); + } +} catch (Throwable $e) { + fwrite(STDERR, "Migration error: " . $e->getMessage() . "\n"); + exit(1); +}