Files
api/services/nginx/app/classes/schema_bootstrap_runtime.php
T
Jeppe Bandbugfix 4c6b60c9f2 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>
2026-08-16 23:00:03 +02:00

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.
}
}
}
}