## Summary Makes the database **self-healing** — every request auto-runs all `*_schema_bootstrap::ensureSchema()` after `$db->connect()`. This catches the "merged-to-master-but-migration-never-applied-to-prod" failure mode that just bit us with TRU-77 (`invoice_email` column). ## Why PR #383 added a pre-deploy schema step to `deploy.yml`. Correct, but requires GitHub secrets (`DEPLOY_SSH_KEY`, `DEPLOY_USER`, `SMOKE_BASE_URL`) that aren't set on the `api` repo yet. Until those secrets exist, the pre-deploy step is skipped and migrations never reach production. Result: API still references `invoice_email` but the column doesn't exist → `Unknown column 'invoice_email' in 'SELECT'`. ## Fix - New class `classes/schema_bootstrap_runtime.php`: - Auto-discovers all `*_schema_bootstrap.php` files in `classes/` - Calls `ensureSchema()` on each - Memoized per PHP process (`private static bool $ran = false`) - One failure does not block others (logged, not thrown) - New hook in `services/nginx/app/index.php` right after `$db->connect()`: ```php try { \classes\schema_bootstrap_runtime::runAll(); } catch (Throwable $e) { error_log('[schema-bootstrap] runtime::runAll() failed: ' . $e->getMessage()); } ``` - New test: `tests/Unit/SchemaBootstrapRuntimeTest.php` (3 cases) ## Safety Each existing `*_schema_bootstrap` is **additive + idempotent**: - `SHOW COLUMNS` check before any `ALTER` - `ALTER TABLE ADD COLUMN` only if missing - Per-class `private static bool $initialized = false` short-circuit - Errors logged but never break the request So: first request after deploy adds missing columns. Every subsequent request hits the in-process `$ran` short-circuit (~microseconds). The new column then exists, the API works, error goes away. ## Test plan 1. Wait for CI (PHP unit + integration) 2. Merge to master 3. Production auto-deploys (or manual re-deploy if secrets not set) 4. Hit the failing endpoint — first request will auto-migrate, response should be 200 5. Verify with `GET /api/admin/schema-check` that all columns are present ## Rollback If anything goes wrong, revert the merge commit. The runtime class only auto-discovers files matching `*_schema_bootstrap.php`; removing it reverts the system to the pre-deploy-step-only behavior. --- **Closes** the TRU-77 follow-up: the "Unknown column 'invoice_email' in 'SELECT'" error should never recur, because the code now self-heals regardless of whether the deploy pre-deploy step ran. --------- Co-authored-by: bugfix <bugfix@truckwash.local>
83 lines
2.7 KiB
PHP
83 lines
2.7 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
/**
|
|
* Self-healing schema bootstrap.
|
|
*
|
|
* Runs every `*_schema_bootstrap::ensureSchema()` on app start so the
|
|
* production database always has the columns the current code expects.
|
|
* This catches the "merged-to-master-but-never-applied-to-prod" failure
|
|
* mode (e.g. TRU-77 invoice_email) where the deploy pipeline pre-deploy
|
|
* step didn't run (missing GitHub secrets, network glitch, etc.).
|
|
*
|
|
* Each bootstrap is **additive + idempotent**:
|
|
* - SHOW COLUMNS check before any ALTER
|
|
* - ALTER TABLE ADD COLUMN only if missing
|
|
* - Once `ensureSchema()` has been called once for a class, the static
|
|
* `$initialized` flag short-circuits subsequent calls
|
|
*
|
|
* The discovery + run loop itself is memoized per PHP process via
|
|
* `self::$ran`, so the cost after the first request is a single
|
|
* `class_exists` check (~microseconds).
|
|
*
|
|
* Errors in a single bootstrap are logged but never throw — a broken
|
|
* migration must not 500 every request. A future /api/admin/schema-check
|
|
* call will surface the failure.
|
|
*/
|
|
class schema_bootstrap_runtime
|
|
{
|
|
/** @var bool Memoization for the discovery+run loop */
|
|
private static bool $ran = false;
|
|
|
|
/** @var string[] Class names that already failed this process (don't retry) */
|
|
private static array $failed = [];
|
|
|
|
public static function runAll(): void
|
|
{
|
|
if (self::$ran) {
|
|
return;
|
|
}
|
|
self::$ran = true;
|
|
|
|
$classesDir = __DIR__;
|
|
$bootstraps = glob($classesDir . DIRECTORY_SEPARATOR . '*_schema_bootstrap.php');
|
|
if (!$bootstraps) {
|
|
return;
|
|
}
|
|
|
|
foreach ($bootstraps as $file) {
|
|
$base = basename($file, '.php');
|
|
$class = "classes\\{$base}";
|
|
|
|
if (in_array($class, self::$failed, true)) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
if (!class_exists($class)) {
|
|
require_once $file;
|
|
}
|
|
if (!class_exists($class)) {
|
|
continue;
|
|
}
|
|
if (!method_exists($class, 'ensureSchema')) {
|
|
continue;
|
|
}
|
|
$class::ensureSchema();
|
|
} catch (\Throwable $e) {
|
|
self::$failed[] = $class;
|
|
error_log(sprintf(
|
|
'[schema-bootstrap] %s failed: %s',
|
|
$base,
|
|
$e->getMessage()
|
|
));
|
|
// Intentionally do not throw — a broken migration must
|
|
// not 500 every request. The next /api/admin/schema-check
|
|
// call (or the next deploy's pre-deploy step) will
|
|
// surface the failure.
|
|
}
|
|
}
|
|
}
|
|
}
|