array_values($applied)]; } function manageMigrationsAppliedIds(): array { $ids = []; foreach (manageMigrationsReadState()["applied"] as $entry) { if (is_array($entry) && ($entry["id"] ?? "") !== "") { $ids[] = (string) $entry["id"]; } } return $ids; } function manageMigrationsRecordApplied(string $id, int $durationMs): void { $state = manageMigrationsReadState(); $state["applied"][] = [ "id" => $id, "applied_at" => date(DATE_ATOM), "version" => manageClientVersion(), "duration_ms" => $durationMs, ]; manageWriteJson(manageMigrationsStateFile(), $state); } /** * All migration files in the package, sorted by filename. * * The filename without .php is the migration id, so renaming an already applied * migration makes it run again. That is documented, not accidental. */ function manageMigrationsAvailable(): array { if (!manageMigrationsEnabled() || !is_dir(manageMigrationsDir())) { return []; } $migrations = []; foreach (glob(manageMigrationsDir() . "*.php") ?: [] as $path) { if (!is_file($path) || !is_readable($path)) { continue; } $id = basename($path, ".php"); if ($id === "" || $id[0] === ".") { continue; } $migrations[] = ["id" => $id, "path" => $path]; } usort($migrations, static function (array $left, array $right): int { return strcmp($left["id"], $right["id"]); }); return $migrations; } /** * Migrations that have not been applied yet, in execution order. */ function manageUpdatePendingMigrations(): array { $applied = manageMigrationsAppliedIds(); $pending = []; foreach (manageMigrationsAvailable() as $migration) { if (!in_array($migration["id"], $applied, true)) { $pending[] = $migration; } } return $pending; } // Builds the context handed to every migration and to the post-update hook. function manageHookContext(array $extra = []): array { $context = array_merge([ "app_root" => manageClientAppRoot(), "instance" => (string) MANAGE_INSTANCE, "from_version" => "", "to_version" => manageClientVersion(), "backup_dir" => "", "run_id" => "", ], $extra); // A database-backed project gets a ready connection, so a migration never // has to duplicate the credentials that are already configured for backups. if (manageDatabaseConfigured()) { $context["pdo"] = manageDatabaseConnect(); } return $context; } /** * Loads one migration file and returns its callable. * * Two supported shapes: * return function (array $context): void { ... }; * function up(array $context): void { ... } // defined in the file */ function manageMigrationResolveCallable(array $migration): callable { $returned = require $migration["path"]; if (is_callable($returned)) { return $returned; } if (function_exists("up")) { return "up"; } throw new RuntimeException( "Migration " . $migration["id"] . " liefert keine Funktion zurück und definiert kein up().", ); } /** * Runs all pending migrations in order. * * Stops at the first failure; later migrations stay pending. Returns a report * rather than throwing, so a caller can distinguish "deployment succeeded but * a migration failed" from "deployment failed". * * @return array{success: bool, applied: array, failed: string|null, error: string|null, pending: int} */ function manageUpdateRunMigrations(array $context = []): array { $report = [ "success" => true, "applied" => [], "failed" => null, "error" => null, "pending" => 0, ]; $pending = manageUpdatePendingMigrations(); if ($pending === []) { return $report; } $baseContext = manageHookContext($context); foreach ($pending as $position => $migration) { $startedAt = microtime(true); try { // Each migration is loaded in its own function scope. A file that // defines up() twice across two migrations would collide, which is // why the "return a closure" form is the documented default. $callable = manageMigrationResolveCallable($migration); $callable(array_merge($baseContext, ["migration_id" => $migration["id"]])); } catch (Throwable $exception) { $report["success"] = false; $report["failed"] = $migration["id"]; $report["error"] = $exception->getMessage(); $report["pending"] = count($pending) - $position; manageClientLog("ERROR", "Migration failed", [ "migration" => $migration["id"], "error" => $exception->getMessage(), ]); return $report; } $durationMs = (int) round((microtime(true) - $startedAt) * 1000); manageMigrationsRecordApplied($migration["id"], $durationMs); $report["applied"][] = $migration["id"]; manageClientLog("INFO", "Migration applied", [ "migration" => $migration["id"], "duration_ms" => $durationMs, ]); } return $report; } /** * Runs the configured project callback. * * @return array{configured: bool, success: bool, error: string|null} */ function manageUpdateRunPostHookCallback(array $context = []): array { $hook = MANAGE_UPDATE_POST_HOOK; if (!is_array($hook) || ($hook["callback"] ?? null) === null) { return ["configured" => false, "success" => true, "error" => null]; } try { $file = trim((string) ($hook["file"] ?? "")); if ($file !== "") { if (!is_file($file)) { throw new RuntimeException("Hook-Datei wurde nicht gefunden: " . $file); } require_once $file; } $callback = $hook["callback"]; if (!is_callable($callback)) { throw new RuntimeException( "Hook-Callback ist nicht aufrufbar: " . (is_string($callback) ? $callback : gettype($callback)), ); } $result = call_user_func($callback, manageHookContext($context)); if ($result === false || (is_array($result) && ($result["success"] ?? true) === false)) { $error = is_array($result) ? trim((string) ($result["error"] ?? "")) : ""; throw new RuntimeException( "Post-Update-Hook meldet einen Fehler" . ($error !== "" ? ": " . $error : "."), ); } } catch (Throwable $exception) { manageClientLog("ERROR", "Post-update hook failed", [ "error" => $exception->getMessage(), ]); return ["configured" => true, "success" => false, "error" => $exception->getMessage()]; } manageClientLog("INFO", "Post-update hook finished", []); return ["configured" => true, "success" => true, "error" => null]; } /** * Full post-update step: migrations first, then the project callback. * * Migrations run first so the callback can rely on the new schema. When a * migration fails the callback is skipped, because running it against a * half-migrated state is worse than not running it at all. * * @return array{success: bool, migrations: array, hook: array, error: string|null, failed_migration: string|null} */ function manageUpdateRunPostHook(array $context = []): array { $migrations = manageUpdateRunMigrations($context); if (!$migrations["success"]) { return [ "success" => false, "migrations" => $migrations, "hook" => ["configured" => false, "success" => true, "error" => null, "skipped" => true], "error" => $migrations["error"], "failed_migration" => $migrations["failed"], ]; } $hook = manageUpdateRunPostHookCallback(array_merge($context, [ "migrations" => $migrations["applied"], ])); return [ "success" => $hook["success"], "migrations" => $migrations, "hook" => $hook, "error" => $hook["error"], "failed_migration" => null, ]; }