feat: app reports ready only after the startup migration run (E00-S03-T06)
This commit is contained in:
@@ -2,26 +2,60 @@
|
||||
* @personal-blog/server — EPPP public server application.
|
||||
*
|
||||
* [E00-S02-T03] minimal serving process: a small HTTP server built on Node's
|
||||
* `node:http` (no runtime dependencies yet) that answers the application
|
||||
* health endpoint. `GET /health` reports a healthy application — HTTP 200 with
|
||||
* `{"status":"ok"}` — so the Compose stack's `app` service stays up and the
|
||||
* health endpoint succeeds once the stack is running.
|
||||
* `node:http` that answers the application health endpoint. `GET /health`
|
||||
* reports the application readiness — HTTP 200 with `{"status":"ok"}` — so
|
||||
* the Compose stack's `app` service stays up and the health endpoint
|
||||
* succeeds once the stack is running.
|
||||
*
|
||||
* [E00-S03-T06] readiness gate: the app does **not** report ready before
|
||||
* migrations complete. When a `DATABASE_URL` is configured, the server runs
|
||||
* the startup migrations (the `MigrationRunner` from
|
||||
* `@personal-blog/database-postgres` over the migration ledger, E00-S03-T03)
|
||||
* before it reports ready: `GET /health` answers HTTP 503 with
|
||||
* `{"status":"not ready"}` while the migration run is in flight, and flips to
|
||||
* HTTP 200 `{"status":"ok"}` only after the run finishes. When no
|
||||
* `DATABASE_URL` is configured (e.g. the local non-container developer path,
|
||||
* E00-S01-T06) there are no migrations to run, so the app reports ready
|
||||
* immediately.
|
||||
*
|
||||
* The Fastify 5 application shell (and the real HTTP API) lands in a later
|
||||
* story; this bootstrap keeps the application health-checkable until then.
|
||||
*/
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { Pool } from '@personal-blog/database-postgres';
|
||||
import { MigrationLedger, MigrationRunner } from '@personal-blog/database-postgres';
|
||||
import type { Migration } from '@personal-blog/database-postgres';
|
||||
|
||||
/** Port the server listens on; `PORT` overrides the container default (3000). */
|
||||
const PORT = resolvePort(process.env.PORT);
|
||||
|
||||
/** Health payload — reports a healthy application. */
|
||||
/** Health payload — reported once the startup migration run completes. */
|
||||
const HEALTH_PAYLOAD = JSON.stringify({ status: 'ok' });
|
||||
|
||||
/** Payload while the startup migration run is still in flight — the app is up but NOT ready. */
|
||||
const NOT_READY_PAYLOAD = JSON.stringify({ status: 'not ready' });
|
||||
|
||||
/** Payload for any route that is not the health endpoint. */
|
||||
const NOT_FOUND_PAYLOAD = JSON.stringify({ error: 'not found' });
|
||||
|
||||
/**
|
||||
* The migrations the server applies at startup, oldest first. Empty until the
|
||||
* first schema migration lands (E00-S03 is the migration-runner foundation;
|
||||
* real schema migrations arrive with the domain stories). The runner still
|
||||
* ensures the migration ledger (E00-S03-T03) exists and reads the applied
|
||||
* versions, so even an empty run is a real, observable migration step that
|
||||
* readiness waits for.
|
||||
*/
|
||||
const MIGRATIONS: readonly Migration[] = [];
|
||||
|
||||
/**
|
||||
* Readiness state — false until the startup migration run completes. The app
|
||||
* is "up" (the HTTP server is listening) but reports not-ready (E00-S03-T06)
|
||||
* while migrations are pending.
|
||||
*/
|
||||
let migrationsComplete = false;
|
||||
|
||||
/**
|
||||
* Resolves the listen port from `PORT` (default 3000, matching the Dockerfile
|
||||
* `EXPOSE 3000` and the compose `:3000` container port). A non-numeric or
|
||||
@@ -44,11 +78,17 @@ function sendJson(res: ServerResponse, statusCode: number, body: string): void {
|
||||
|
||||
/**
|
||||
* Routes one request. The application only serves the health endpoint at this
|
||||
* stage; anything else is a 404 so misconfiguration is loud.
|
||||
* stage; anything else is a 404 so misconfiguration is loud. The health route
|
||||
* is the readiness probe (E00-S03-T06): it answers 200 only after the startup
|
||||
* migration run completes, and 503 while the run is still pending.
|
||||
*/
|
||||
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
if (req.method === 'GET' && (req.url ?? '/') === '/health') {
|
||||
sendJson(res, 200, HEALTH_PAYLOAD);
|
||||
if (migrationsComplete) {
|
||||
sendJson(res, 200, HEALTH_PAYLOAD);
|
||||
} else {
|
||||
sendJson(res, 503, NOT_READY_PAYLOAD);
|
||||
}
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, NOT_FOUND_PAYLOAD);
|
||||
@@ -56,6 +96,35 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
|
||||
const server = createServer(handleRequest);
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (databaseUrl === undefined) {
|
||||
// No DATABASE_URL configured (e.g. local non-container dev): there are no
|
||||
// migrations to run, so the app reports ready from the start.
|
||||
migrationsComplete = true;
|
||||
console.log('[migrate] no DATABASE_URL configured; reporting ready without a migration run');
|
||||
} else {
|
||||
// E00-S03-T06: run the startup migrations; readiness follows completion.
|
||||
const pool = new Pool({ connectionString: databaseUrl });
|
||||
const runner = new MigrationRunner(pool, MIGRATIONS, new MigrationLedger(pool));
|
||||
runner
|
||||
.run()
|
||||
.then((result) => {
|
||||
migrationsComplete = true;
|
||||
console.log(
|
||||
`[migrate] startup migration run complete (applied ${result.applied.length}, skipped ${result.skipped.length}); reporting ready`,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
// Failure diagnostics are E00-S03-T05 (out of scope for T06): the
|
||||
// runner already throws a serializable MigrationFailedError. The app
|
||||
// logs the failure and stays not-ready, so a deployment with failed
|
||||
// migrations is surfaced by the readiness probe instead of
|
||||
// crash-looping.
|
||||
console.error('[migrate] startup migration run failed; app stays not-ready:', String(error));
|
||||
});
|
||||
}
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`@personal-blog/server listening on http://0.0.0.0:${PORT} (health: GET /health)`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user