From ebdee5daa58c271c874f2a816cf317910599ecf8 Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 01:56:19 +0000 Subject: [PATCH] feat: app reports ready only after the startup migration run (E00-S03-T06) --- apps/server/Dockerfile | 32 +++++++-- apps/server/package.json | 9 ++- apps/server/src/index.ts | 83 ++++++++++++++++++++++-- packages/database-postgres/src/runner.ts | 6 +- pnpm-lock.yaml | 4 ++ 5 files changed, 118 insertions(+), 16 deletions(-) diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index aac22f9..fd75385 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -5,8 +5,12 @@ # [E00-S02-T01/T02/T03] baseline: builds the workspace server package with the # pinned toolchain (Node 24.19.0 + pnpm 11.23.0, frozen lockfile) and runs the # compiled entrypoint. Since T03 the entrypoint is a minimal Node `node:http` -# server answering `GET /health` with `{"status":"ok"}` (HTTP 200) on port -# 3000, so the app container stays up and the health endpoint succeeds. The +# server answering `GET /health` on port 3000. Since T06 (E00-S03) the health +# endpoint is the readiness probe: the app runs the startup migrations (the +# `MigrationRunner` from `@personal-blog/database-postgres`) before reporting +# ready — `GET /health` answers 503 `{"status":"not ready"}` while the run is +# in flight and flips to 200 `{"status":"ok"}` only after it completes — so +# the app container does not report ready before migrations complete. The # Fastify 5 application shell (and the real HTTP API) lands in a later story; # DB volume persistence (T04), read-only root filesystem (T06) and multi-arch # build targets (T07) are Compose-level concerns (see compose.yaml — the @@ -17,6 +21,15 @@ # T05 the runtime stage drops root privileges (runs as the image's non-root # `node` user). # +# The app now depends on the `database-postgres` workspace package (the single +# owner of the pg/Kysely driver, E00-S03-T02). The build stage therefore also +# installs/builds that package — the server's `build`/`typecheck` scripts +# build their workspace dependency first (`pnpm --filter +# @personal-blog/database-postgres build`), and the runtime stage ships the +# compiled `packages/database-postgres/dist` next to the copied workspace +# node_modules links so the server's `@personal-blog/database-postgres` import +# resolves at run time. +# # T08: the image embeds no secrets. The Dockerfile declares no secret-bearing # ARG/ENV instruction (the only ENV is `NODE_ENV=production`) and every COPY # copies a fixed, non-secret path (manifests, source, compiled dist) — never @@ -44,15 +57,21 @@ RUN corepack enable # invalidate the dependency layer, then install against the committed lockfile # (the same `--frozen-lockfile` path CI and developers use). Every workspace # package manifest is copied so the in-image workspace matches the lockfile -# importers exactly (apps/server, packages/core, extensions/example). +# importers exactly (apps/server, packages/core, packages/database-postgres, +# extensions/example). COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ COPY apps/server/package.json apps/server/package.json COPY packages/core/package.json packages/core/package.json +COPY packages/database-postgres/package.json packages/database-postgres/package.json COPY extensions/example/package.json extensions/example/package.json RUN pnpm install --frozen-lockfile -# Compile the server package (tsc -p apps/server/tsconfig.json -> dist/). +# Compile the server package (tsc -p apps/server/tsconfig.json -> dist/). The +# server's build script builds its workspace dependency first (the +# `database-postgres` package, whose compiled dist the server imports), so a +# single command produces both dists in the right order. COPY apps/server apps/server +COPY packages/database-postgres packages/database-postgres RUN pnpm --filter @personal-blog/server build # --- runtime stage: Node 24.19.0 (bookworm-slim) + compiled output only ------ @@ -61,10 +80,13 @@ WORKDIR /app ENV NODE_ENV=production # The workspace install (devDependencies included — image-size pruning is a -# later E00-S02 concern) plus the compiled server output and manifest. +# later E00-S02 concern) plus the compiled server output, the compiled +# database-postgres output the server imports, and the package manifests. COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/apps/server/dist ./apps/server/dist COPY --from=build /app/apps/server/package.json ./apps/server/package.json +COPY --from=build /app/packages/database-postgres/dist ./packages/database-postgres/dist +COPY --from=build /app/packages/database-postgres/package.json ./packages/database-postgres/package.json # T05: run as the image's non-root `node` user (uid/gid 1000, shipped with the # official Node image) so the app container does not run with root privileges. diff --git a/apps/server/package.json b/apps/server/package.json index 62d5b7e..026f594 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -3,12 +3,15 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03); the Fastify 5 application shell lands in a later story.", + "description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06); the Fastify 5 application shell lands in a later story.", "scripts": { - "build": "tsc -p tsconfig.json", - "typecheck": "tsc -p tsconfig.json --noEmit", + "build": "pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json", + "typecheck": "pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json --noEmit", "start": "node dist/index.js" }, + "dependencies": { + "@personal-blog/database-postgres": "workspace:*" + }, "devDependencies": { "@types/node": "24.13.3" }, diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index aad34d0..c99ebaf 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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)`); }); diff --git a/packages/database-postgres/src/runner.ts b/packages/database-postgres/src/runner.ts index f2f36c5..590b768 100644 --- a/packages/database-postgres/src/runner.ts +++ b/packages/database-postgres/src/runner.ts @@ -28,6 +28,9 @@ * allowed to import the PostgreSQL driver (E00-S03-T02) — and talks to the * database exclusively through the package-owned `pg` Pool and the * `MigrationLedger`, so no other package needs the driver to run migrations. + * The app (apps/server) runs this runner at startup and gates its readiness + * on the run (E00-S03-T06): it does not report ready before the run + * completes. * * Rollback note from the issue: revert the diagnostic/error handling changes. */ @@ -128,7 +131,8 @@ function structuredCause(cause: unknown): Record { * The migration runner: applies pending migrations in order, exactly once, * through the migration ledger. Instances are cheap and share the caller's * pool and ledger; the runner performs no locking (advisory lock is - * E00-S03-T04) and no ready gating (E00-S03-T06). + * E00-S03-T04) and no ready gating (the app gates its readiness on the run, + * E00-S03-T06). */ export class MigrationRunner { private readonly pool: Pool; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbef49b..406e0c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,10 @@ importers: version: 6.0.3 apps/server: + dependencies: + '@personal-blog/database-postgres': + specifier: workspace:* + version: link:../../packages/database-postgres devDependencies: '@types/node': specifier: 24.13.3