fix(api): self-healing schema bootstrap on every request (TRU-77 follow-up) (#387)
## 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>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
<?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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user