seedproject-web/api/app/Services/Migrator.php
Carlos Arias 1fdfc3174b feat(api): Foundation — installer, migrations, two-tier auth, health round-trip
Builds the /api Foundation into the base (per api/.memory/foundation-plan.md):
- app/Services/Installer.php  DB test + dump.sql import + crypto keys + /api config + lock
- app/Services/Migrator.php   versioned db/migrations/*.sql runner (+ migrations table)
- commands/InstallCommand.php (app:install), commands/MigrateCommand.php (db:migrate)
- app/Controllers/{JsonController,PublicController,ApiController}  envelope + two-tier auth
- public/controllers/{health,admin}.php  GET /api/health (public), /api/admin/ping (bearer)
- db/migrations/001_*.sql  smoke migration
- install/controllers/index.php  web wizard now delegates to Installer (path bugs fixed, lock)
- console + composer.json  register commands / add commands to classmap
- app/src/pages/api-health-test.astro  browser round-trip proof page

Verified (no DB): composer install OK; php console lists app:install + db:migrate;
PSR-4 classes autoload; CLI fails gracefully (validation, bad DB, missing config) with
no artifacts left; app builds 14 pages. Live DB + HTTP round-trip pending a served instance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYHWLHihq3v9nxNwoPCKSn
2026-07-04 23:23:51 +00:00

57 lines
1.7 KiB
PHP

<?php
namespace App\Services;
use PDO;
/** Applies ordered *.sql files from a migrations dir, tracked in a `migrations` table. */
class Migrator
{
private PDO $pdo;
private string $dir;
public function __construct(PDO $pdo, string $dir)
{
$this->pdo = $pdo;
$this->dir = $dir;
}
public function ensureTable(): void
{
$this->pdo->exec(
"CREATE TABLE IF NOT EXISTS `migrations` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`filename` VARCHAR(255) NOT NULL UNIQUE,
`applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
}
/** @return string[] filenames already applied */
public function applied(): array
{
return $this->pdo->query("SELECT filename FROM `migrations`")->fetchAll(PDO::FETCH_COLUMN) ?: [];
}
/** @return string[] absolute paths of pending migrations, in order */
public function pending(): array
{
$all = glob($this->dir . '/*.sql') ?: [];
sort($all);
$applied = $this->applied();
return array_values(array_filter($all, fn($p) => !in_array(basename($p), $applied, true)));
}
/** @return string[] filenames applied this run */
public function migrate(): array
{
$this->ensureTable();
$done = [];
foreach ($this->pending() as $path) {
$this->pdo->exec((string) file_get_contents($path));
$stmt = $this->pdo->prepare("INSERT IGNORE INTO `migrations` (filename) VALUES (?)");
$stmt->execute([basename($path)]);
$done[] = basename($path);
}
return $done;
}
}