diff --git a/services/nginx/app/classes/schema_bootstrap_runtime.php b/services/nginx/app/classes/schema_bootstrap_runtime.php new file mode 100644 index 00000000..6c058523 --- /dev/null +++ b/services/nginx/app/classes/schema_bootstrap_runtime.php @@ -0,0 +1,82 @@ +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. + } + } + } +} diff --git a/services/nginx/app/index.php b/services/nginx/app/index.php index 4795355a..72f23d9d 100644 --- a/services/nginx/app/index.php +++ b/services/nginx/app/index.php @@ -213,6 +213,18 @@ try { $response->error($e->getMessage(), 500); } +// Self-healing schema bootstrap. Runs every *_schema_bootstrap::ensureSchema() +// once per process. Each is additive + idempotent (SHOW COLUMNS check before +// any ALTER), so this is safe on every request. Catches the +// "merged-to-master-but-migration-never-applied" failure mode (e.g. TRU-77 +// invoice_email) even when the deploy pipeline pre-deploy step is skipped +// (missing GitHub secrets, network glitch, manual deploy, etc.). +try { + \classes\schema_bootstrap_runtime::runAll(); +} catch (Throwable $e) { + error_log('[schema-bootstrap] runtime::runAll() failed: ' . $e->getMessage()); +} + try { release_manager::initializeRequestContext(); $releaseIngressPath = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: ''); diff --git a/services/nginx/app/tests/Unit/SchemaBootstrapRuntimeTest.php b/services/nginx/app/tests/Unit/SchemaBootstrapRuntimeTest.php new file mode 100644 index 00000000..4fdd880a --- /dev/null +++ b/services/nginx/app/tests/Unit/SchemaBootstrapRuntimeTest.php @@ -0,0 +1,51 @@ +assertNotEmpty($bootstraps, 'No *_schema_bootstrap.php files found in classes/'); + + // Ensure no real $db is required: each ensureSchema() in the + // existing classes guards with `if (!isset($db) ...) { return; }` + // so they are no-ops without one. We just verify the runtime + // doesn't throw. + schema_bootstrap_runtime::runAll(); + $this->assertTrue(true); // no exception + } + + public function testRunAllIsIdempotent(): void + { + // First call already happened in test 1; calling again must + // short-circuit and not throw. + schema_bootstrap_runtime::runAll(); + schema_bootstrap_runtime::runAll(); + $this->assertTrue(true); + } + + public function testNoOpWhenNoBootstrapsExist(): void + { + // Reflection: ensure runAll() is robust even if a different + // classes dir somehow had no bootstraps. We just call it + // again — it should be a no-op due to the static $ran flag. + schema_bootstrap_runtime::runAll(); + $this->assertTrue(true); + } +}