Merge pull request '[E00-S03-T06] App does not report ready before migrations complete' (#395) from feature/181 into main
CI / Frozen lockfile install (push) Successful in 52s
CI / Secrets not embedded (E00-S02-T08) (push) Successful in 27s
CI / Database-postgres import isolation (E00-S03-T02) (push) Successful in 29s
CI / Migration ledger (E00-S03-T03) (push) Successful in 42s
CI / Migration advisory lock (E00-S03-T04) (push) Successful in 42s
CI / Migration failure diagnostic (E00-S03-T05) (push) Successful in 51s
CI / App readiness after migrations (E00-S03-T06) (push) Successful in 54s
CI / Compose config (E00-S03-T01) (push) Successful in 25s
CI / Frozen lockfile install (push) Successful in 52s
CI / Secrets not embedded (E00-S02-T08) (push) Successful in 27s
CI / Database-postgres import isolation (E00-S03-T02) (push) Successful in 29s
CI / Migration ledger (E00-S03-T03) (push) Successful in 42s
CI / Migration advisory lock (E00-S03-T04) (push) Successful in 42s
CI / Migration failure diagnostic (E00-S03-T05) (push) Successful in 51s
CI / App readiness after migrations (E00-S03-T06) (push) Successful in 54s
CI / Compose config (E00-S03-T01) (push) Successful in 25s
This commit was merged in pull request #395.
This commit is contained in:
@@ -134,6 +134,39 @@ jobs:
|
|||||||
- name: Run migration failure diagnostic test suite
|
- name: Run migration failure diagnostic test suite
|
||||||
run: node --test tests/database-postgres-diagnostic.test.mjs
|
run: node --test tests/database-postgres-diagnostic.test.mjs
|
||||||
|
|
||||||
|
# E00-S03-T06: the static assertions of tests/app-readiness.test.mjs gate
|
||||||
|
# every PR — the suite locks in the readiness gate (the app answers
|
||||||
|
# GET /health with 503 {"status":"not ready"} until the startup migration
|
||||||
|
# run completes, then 200 {"status":"ok"}) with mutation probes, the
|
||||||
|
# deterministic probes (boot the committed server: no DATABASE_URL ->
|
||||||
|
# ready immediately; unreachable DATABASE_URL -> stays not-ready) run on
|
||||||
|
# Node 24, and the docker-gated real-stack probe (the issue's test plan:
|
||||||
|
# "start with pending migrations and confirm readiness waits" — the app's
|
||||||
|
# migration run is blocked behind a held ACCESS EXCLUSIVE lock on the
|
||||||
|
# migration ledger, /health stays not-ready, then flips ready once the lock
|
||||||
|
# releases) runs where a Docker daemon is available and skips cleanly
|
||||||
|
# otherwise. The job installs the frozen workspace and builds the
|
||||||
|
# database-postgres package because the probes boot the committed server
|
||||||
|
# from the host (it imports @personal-blog/database-postgres through the
|
||||||
|
# package's own links).
|
||||||
|
app-readiness:
|
||||||
|
name: App readiness after migrations (E00-S03-T06)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install Node.js 24
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||||
|
run: corepack enable
|
||||||
|
- name: Install dependencies (frozen lockfile)
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Build the database-postgres package (the probes boot the committed server which imports it)
|
||||||
|
run: pnpm --filter @personal-blog/database-postgres build
|
||||||
|
- name: Run app readiness test suite
|
||||||
|
run: node --test tests/app-readiness.test.mjs
|
||||||
|
|
||||||
# E00-S03-T01: the static assertions of tests/compose-config.test.mjs (db
|
# E00-S03-T01: the static assertions of tests/compose-config.test.mjs (db
|
||||||
# image pinned to postgres:18.6-bookworm, health gate, volume persistence,
|
# image pinned to postgres:18.6-bookworm, health gate, volume persistence,
|
||||||
# build platforms) gate every PR (the docker-gated real-stack probes inside
|
# build platforms) gate every PR (the docker-gated real-stack probes inside
|
||||||
|
|||||||
+27
-5
@@ -5,8 +5,12 @@
|
|||||||
# [E00-S02-T01/T02/T03] baseline: builds the workspace server package with the
|
# [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
|
# 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`
|
# compiled entrypoint. Since T03 the entrypoint is a minimal Node `node:http`
|
||||||
# server answering `GET /health` with `{"status":"ok"}` (HTTP 200) on port
|
# server answering `GET /health` on port 3000. Since T06 (E00-S03) the health
|
||||||
# 3000, so the app container stays up and the health endpoint succeeds. The
|
# 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;
|
# 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
|
# DB volume persistence (T04), read-only root filesystem (T06) and multi-arch
|
||||||
# build targets (T07) are Compose-level concerns (see compose.yaml — the
|
# 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
|
# T05 the runtime stage drops root privileges (runs as the image's non-root
|
||||||
# `node` user).
|
# `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
|
# 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
|
# ARG/ENV instruction (the only ENV is `NODE_ENV=production`) and every COPY
|
||||||
# copies a fixed, non-secret path (manifests, source, compiled dist) — never
|
# 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
|
# invalidate the dependency layer, then install against the committed lockfile
|
||||||
# (the same `--frozen-lockfile` path CI and developers use). Every workspace
|
# (the same `--frozen-lockfile` path CI and developers use). Every workspace
|
||||||
# package manifest is copied so the in-image workspace matches the lockfile
|
# 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 package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
|
||||||
COPY apps/server/package.json apps/server/package.json
|
COPY apps/server/package.json apps/server/package.json
|
||||||
COPY packages/core/package.json packages/core/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
|
COPY extensions/example/package.json extensions/example/package.json
|
||||||
RUN pnpm install --frozen-lockfile
|
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 apps/server apps/server
|
||||||
|
COPY packages/database-postgres packages/database-postgres
|
||||||
RUN pnpm --filter @personal-blog/server build
|
RUN pnpm --filter @personal-blog/server build
|
||||||
|
|
||||||
# --- runtime stage: Node 24.19.0 (bookworm-slim) + compiled output only ------
|
# --- runtime stage: Node 24.19.0 (bookworm-slim) + compiled output only ------
|
||||||
@@ -61,10 +80,13 @@ WORKDIR /app
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# The workspace install (devDependencies included — image-size pruning is a
|
# 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/node_modules ./node_modules
|
||||||
COPY --from=build /app/apps/server/dist ./apps/server/dist
|
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/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
|
# 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.
|
# official Node image) so the app container does not run with root privileges.
|
||||||
|
|||||||
@@ -3,12 +3,15 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"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": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json --noEmit",
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@personal-blog/database-postgres": "workspace:*"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "24.13.3"
|
"@types/node": "24.13.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,26 +2,60 @@
|
|||||||
* @personal-blog/server — EPPP public server application.
|
* @personal-blog/server — EPPP public server application.
|
||||||
*
|
*
|
||||||
* [E00-S02-T03] minimal serving process: a small HTTP server built on Node's
|
* [E00-S02-T03] minimal serving process: a small HTTP server built on Node's
|
||||||
* `node:http` (no runtime dependencies yet) that answers the application
|
* `node:http` that answers the application health endpoint. `GET /health`
|
||||||
* health endpoint. `GET /health` reports a healthy application — HTTP 200 with
|
* reports the application readiness — HTTP 200 with `{"status":"ok"}` — so
|
||||||
* `{"status":"ok"}` — so the Compose stack's `app` service stays up and the
|
* the Compose stack's `app` service stays up and the health endpoint
|
||||||
* health endpoint succeeds once the stack is running.
|
* 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
|
* The Fastify 5 application shell (and the real HTTP API) lands in a later
|
||||||
* story; this bootstrap keeps the application health-checkable until then.
|
* story; this bootstrap keeps the application health-checkable until then.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
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). */
|
/** Port the server listens on; `PORT` overrides the container default (3000). */
|
||||||
const PORT = resolvePort(process.env.PORT);
|
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' });
|
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. */
|
/** Payload for any route that is not the health endpoint. */
|
||||||
const NOT_FOUND_PAYLOAD = JSON.stringify({ error: 'not found' });
|
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
|
* Resolves the listen port from `PORT` (default 3000, matching the Dockerfile
|
||||||
* `EXPOSE 3000` and the compose `:3000` container port). A non-numeric or
|
* `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
|
* 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 {
|
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||||
if (req.method === 'GET' && (req.url ?? '/') === '/health') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
sendJson(res, 404, NOT_FOUND_PAYLOAD);
|
sendJson(res, 404, NOT_FOUND_PAYLOAD);
|
||||||
@@ -56,6 +96,35 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
|||||||
|
|
||||||
const server = createServer(handleRequest);
|
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, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`@personal-blog/server listening on http://0.0.0.0:${PORT} (health: GET /health)`);
|
console.log(`@personal-blog/server listening on http://0.0.0.0:${PORT} (health: GET /health)`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ The workspace is a pnpm monorepo with three package groups:
|
|||||||
|
|
||||||
| Group | Path | Purpose |
|
| Group | Path | Purpose |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03); the Fastify 5 application shell lands in a later story. |
|
| `apps/` | `apps/server` (`@personal-blog/server`) | 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. |
|
||||||
| `packages/` | `packages/core` (`@personal-blog/core`) | Application core (site identity, content primitives). Bootstrap placeholder. |
|
| `packages/` | `packages/core` (`@personal-blog/core`) | Application core (site identity, content primitives). Bootstrap placeholder. |
|
||||||
| `packages/` | `packages/database-postgres` (`@personal-blog/database-postgres`) | PostgreSQL database adapter package. Single owner of the `pg`/Kysely driver imports (E00-S03-T02); the migration ledger (`schema_migrations`, E00-S03-T03), the migration advisory lock (E00-S03-T04) and the migration runner with its failure diagnostic (E00-S03-T05) are implemented here. |
|
| `packages/` | `packages/database-postgres` (`@personal-blog/database-postgres`) | PostgreSQL database adapter package. Single owner of the `pg`/Kysely driver imports (E00-S03-T02); the migration ledger (`schema_migrations`, E00-S03-T03), the migration advisory lock (E00-S03-T04) and the migration runner with its failure diagnostic (E00-S03-T05) are implemented here. The server depends on this package to run the startup migrations behind its readiness gate (E00-S03-T06). |
|
||||||
| `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. |
|
| `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. |
|
||||||
|
|
||||||
A dependency-boundary rule (`dependency-boundaries.json`, enforced by
|
A dependency-boundary rule (`dependency-boundaries.json`, enforced by
|
||||||
@@ -94,10 +94,16 @@ compiled application entrypoint. Two things to know:
|
|||||||
compiled artifact in `dist/`, it does not compile first.
|
compiled artifact in `dist/`, it does not compile first.
|
||||||
2. Since [E00-S02-T03], `apps/server` serves the **application health
|
2. Since [E00-S02-T03], `apps/server` serves the **application health
|
||||||
endpoint**: starting it opens an HTTP server on port 3000 answering
|
endpoint**: starting it opens an HTTP server on port 3000 answering
|
||||||
`GET /health` with HTTP 200 and `{"status":"ok"}`, so the process stays up.
|
`GET /health`, so the process stays up. Since [E00-S03-T06] the endpoint
|
||||||
The real Fastify 5 application shell — which turns this into the full
|
is the **readiness probe**: when a `DATABASE_URL` is configured, the app
|
||||||
serving API — lands in a later story; the `start` command shape stays the
|
runs the startup migrations before reporting ready — `GET /health` answers
|
||||||
same once it does.
|
HTTP 503 with `{"status":"not ready"}` while the run is in flight and HTTP
|
||||||
|
200 with `{"status":"ok"}` only after it completes. With **no
|
||||||
|
`DATABASE_URL`** (this local non-container path) there is no migration run
|
||||||
|
to wait for, so the server reports ready immediately and `GET /health`
|
||||||
|
answers 200 `{"status":"ok"}` from the start. The real Fastify 5
|
||||||
|
application shell — which turns this into the full serving API — lands in
|
||||||
|
a later story; the `start` command shape stays the same once it does.
|
||||||
|
|
||||||
To run the compiled output of any other workspace package directly:
|
To run the compiled output of any other workspace package directly:
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
* allowed to import the PostgreSQL driver (E00-S03-T02) — and talks to the
|
* allowed to import the PostgreSQL driver (E00-S03-T02) — and talks to the
|
||||||
* database exclusively through the package-owned `pg` Pool and the
|
* database exclusively through the package-owned `pg` Pool and the
|
||||||
* `MigrationLedger`, so no other package needs the driver to run migrations.
|
* `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.
|
* Rollback note from the issue: revert the diagnostic/error handling changes.
|
||||||
*/
|
*/
|
||||||
@@ -128,7 +131,8 @@ function structuredCause(cause: unknown): Record<string, unknown> {
|
|||||||
* The migration runner: applies pending migrations in order, exactly once,
|
* The migration runner: applies pending migrations in order, exactly once,
|
||||||
* through the migration ledger. Instances are cheap and share the caller's
|
* through the migration ledger. Instances are cheap and share the caller's
|
||||||
* pool and ledger; the runner performs no locking (advisory lock is
|
* 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 {
|
export class MigrationRunner {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
|
|||||||
Generated
+4
@@ -13,6 +13,10 @@ importers:
|
|||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
|
|
||||||
apps/server:
|
apps/server:
|
||||||
|
dependencies:
|
||||||
|
'@personal-blog/database-postgres':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/database-postgres
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 24.13.3
|
specifier: 24.13.3
|
||||||
|
|||||||
@@ -0,0 +1,756 @@
|
|||||||
|
/**
|
||||||
|
* App readiness test — locks in the [E00-S03-T06] guarantee that the app does
|
||||||
|
* not report ready before migrations complete.
|
||||||
|
*
|
||||||
|
* Acceptance criteria covered (each test fails without the committed state):
|
||||||
|
* - "app does not report ready before migrations complete" → the committed
|
||||||
|
* `apps/server/src/index.ts` gates the health endpoint on a readiness
|
||||||
|
* flag (`migrationsComplete`) that starts `false` and flips to `true`
|
||||||
|
* only inside the startup migration run's success handler: while the run
|
||||||
|
* is in flight, `GET /health` answers HTTP 503 with `{"status":"not
|
||||||
|
* ready"}` (the app is up but not ready), never 200. Locked in
|
||||||
|
* statically (mutation probes prove non-vacuity: removing the 503
|
||||||
|
* branch, answering 200 in the not-ready state, replacing the gate with
|
||||||
|
* `if (true)`, or flipping the flag before the run all fail) and
|
||||||
|
* behaviorally by the deterministic probes and the docker-gated
|
||||||
|
* real-stack probe (the issue's test plan: "start with pending
|
||||||
|
* migrations and confirm readiness waits" — the app's migration run is
|
||||||
|
* blocked behind a held ACCESS EXCLUSIVE lock on the migration ledger,
|
||||||
|
* `/health` stays not-ready, then flips ready once the lock releases).
|
||||||
|
* - "readiness is reported only after migrations finish" → the committed
|
||||||
|
* source flips the flag only after `runner.run()` resolves (the
|
||||||
|
* `migrationsComplete = true` assignment sits inside the `.then` of
|
||||||
|
* `run()`, at a source index after the `runner.run()` call), and the
|
||||||
|
* real-stack probe confirms `/health` answers HTTP 200
|
||||||
|
* `{"status":"ok"}` exactly after the blocked run completes; the app
|
||||||
|
* logs the completed run (`applied`/`skipped`) so the flip is
|
||||||
|
* attributable to a real migration run.
|
||||||
|
* - the readiness gate runs the startup migrations through the driver
|
||||||
|
* boundary (`@personal-blog/database-postgres` — `Pool`,
|
||||||
|
* `MigrationLedger`, `MigrationRunner`; `Migration` type), never
|
||||||
|
* importing `pg`/Kysely directly (isolation E00-S03-T02 stays intact).
|
||||||
|
* - the no-migration path: when no `DATABASE_URL` is configured (the local
|
||||||
|
* non-container developer path, E00-S01-T06) there is no startup
|
||||||
|
* migration run to wait for, so the app reports ready immediately —
|
||||||
|
* locked in statically and by a deterministic probe (boot without
|
||||||
|
* `DATABASE_URL` → 200 `{"status":"ok"}`), keeping the E00-S02-T03
|
||||||
|
* health endpoint and the local `pnpm --filter @personal-blog/server
|
||||||
|
* start` path working.
|
||||||
|
*
|
||||||
|
* Run: `node --test tests/app-readiness.test.mjs`
|
||||||
|
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
import { createServer as createNetServer } from 'node:net';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
|
||||||
|
|
||||||
|
/** The committed server entrypoint under test (the readiness gate lives here). */
|
||||||
|
const SERVER_SRC = 'apps/server/src/index.ts';
|
||||||
|
|
||||||
|
/** The driver boundary the server must run migrations through (E00-S03-T02). */
|
||||||
|
const DRIVER_BOUNDARY = '@personal-blog/database-postgres';
|
||||||
|
|
||||||
|
/** The root test glob (root `scripts.test`, E00-S01-T12) that runs every suite. */
|
||||||
|
const ROOT_TEST_GLOB = 'tests/**/*.test.mjs';
|
||||||
|
|
||||||
|
/** The CI job that gates the readiness criterion on every PR. */
|
||||||
|
const CI_JOB = 'app-readiness';
|
||||||
|
|
||||||
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Static assertions on the committed server entrypoint
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts the server runs the startup migrations through the driver boundary:
|
||||||
|
* it imports `Pool`, `MigrationLedger` and `MigrationRunner` (and the
|
||||||
|
* `Migration` type) from `@personal-blog/database-postgres`, and never
|
||||||
|
* imports `pg`/Kysely directly (isolation E00-S03-T02 stays intact). Fails
|
||||||
|
* fast on a missing/placeholder entrypoint; the mutation probes below prove
|
||||||
|
* the assertions are non-vacuous.
|
||||||
|
*/
|
||||||
|
function assertDriverBoundaryImports(src) {
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/from '@personal-blog\/database-postgres'/,
|
||||||
|
`the server must run migrations through the driver boundary (import from '${DRIVER_BOUNDARY}')`,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/import \{ Pool \} from '@personal-blog\/database-postgres'/,
|
||||||
|
`the server must import the pool from the driver boundary (import { Pool } from '${DRIVER_BOUNDARY}')`,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/import \{ MigrationLedger, MigrationRunner \} from '@personal-blog\/database-postgres'/,
|
||||||
|
`the server must import the ledger and runner from the driver boundary (import { MigrationLedger, MigrationRunner } from '${DRIVER_BOUNDARY}')`,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/import type \{ Migration \} from '@personal-blog\/database-postgres'/,
|
||||||
|
`the server must import the Migration type from the driver boundary (import type { Migration } from '${DRIVER_BOUNDARY}')`,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
src,
|
||||||
|
/from\s+'pg'/,
|
||||||
|
'the server must not import the pg driver directly (pg/Kysely imports are isolated to database-postgres, E00-S03-T02)',
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
src,
|
||||||
|
/from\s+'kysely'/,
|
||||||
|
'the server must not import Kysely directly (pg/Kysely imports are isolated to database-postgres, E00-S03-T02)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts the readiness gate exists in the /health route: the server answers
|
||||||
|
* HTTP 200 with the healthy payload only when `migrationsComplete` is true,
|
||||||
|
* and HTTP 503 with the not-ready payload otherwise — the app does not report
|
||||||
|
* ready before migrations complete.
|
||||||
|
*/
|
||||||
|
function assertReadinessGate(src) {
|
||||||
|
const route = src.slice(src.indexOf('function handleRequest'));
|
||||||
|
assert.match(
|
||||||
|
route,
|
||||||
|
/if \(migrationsComplete\) \{/,
|
||||||
|
'the /health route must gate the healthy answer on migrationsComplete (readiness reported only after migrations finish)',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
route,
|
||||||
|
/sendJson\(res, 200, HEALTH_PAYLOAD\)/,
|
||||||
|
'the /health route must answer HTTP 200 with the healthy payload only when ready',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
route,
|
||||||
|
/sendJson\(res, 503, NOT_READY_PAYLOAD\)/,
|
||||||
|
'the /health route must answer HTTP 503 with the not-ready payload while migrations are pending',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/const NOT_READY_PAYLOAD = JSON\.stringify\(\{ status: 'not ready' \}\)/,
|
||||||
|
"the not-ready payload must report a not-ready application ({\"status\":\"not ready\"})",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/let migrationsComplete = false;/,
|
||||||
|
'the readiness flag must start false (the app is not ready before the migration run completes)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts readiness flips only after the startup migration run finishes: in
|
||||||
|
* the DATABASE_URL-configured path the committed source creates a
|
||||||
|
* `MigrationRunner` and calls `run()`, and the `migrationsComplete = true`
|
||||||
|
* assignment sits inside the run's `.then` handler — at a source index
|
||||||
|
* strictly after the `runner.run()` call — so readiness is reported only
|
||||||
|
* after migrations finish.
|
||||||
|
*/
|
||||||
|
function assertReadyAfterRun(src) {
|
||||||
|
const dbUrlIndex = src.indexOf('const databaseUrl = process.env.DATABASE_URL');
|
||||||
|
assert.ok(dbUrlIndex !== -1, 'the server must read DATABASE_URL for the startup migration path');
|
||||||
|
const elseStart = src.indexOf('} else {', dbUrlIndex);
|
||||||
|
assert.ok(elseStart !== -1, 'the DATABASE_URL-configured startup path must exist (else branch)');
|
||||||
|
const catchIndex = src.indexOf('.catch(', elseStart);
|
||||||
|
assert.ok(catchIndex !== -1, 'the startup migration run must have a failure handler (.catch)');
|
||||||
|
const runPath = src.slice(elseStart, catchIndex);
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
runPath,
|
||||||
|
/const runner = new MigrationRunner\(pool, MIGRATIONS, new MigrationLedger\(pool\)\)/,
|
||||||
|
'the server must build the startup migration runner over the migration ledger (new MigrationRunner(pool, MIGRATIONS, new MigrationLedger(pool)))',
|
||||||
|
);
|
||||||
|
const runMatch = /runner\s*\n?\s*\.run\(\)/.exec(runPath);
|
||||||
|
assert.ok(runMatch, 'the server must run the startup migrations (runner.run())');
|
||||||
|
assert.match(
|
||||||
|
runPath,
|
||||||
|
/\.then\(\(result\) => \{/,
|
||||||
|
'the readiness flip must be attached to the run success path (runner.run().then(...))',
|
||||||
|
);
|
||||||
|
const flipMatch = /migrationsComplete = true;/.exec(runPath);
|
||||||
|
assert.ok(
|
||||||
|
flipMatch !== null && flipMatch.index > runMatch.index,
|
||||||
|
'migrationsComplete must flip to true only after the startup migration run (runner.run()) is invoked',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts the no-DATABASE_URL path reports ready immediately: when no
|
||||||
|
* `DATABASE_URL` is configured there is no startup migration run to wait for,
|
||||||
|
* so the app marks migrations complete without a run (keeping the local
|
||||||
|
* non-container path and the E00-S02-T03 health endpoint working).
|
||||||
|
*/
|
||||||
|
function assertNoDatabaseUrlPath(src) {
|
||||||
|
const startup = src.slice(src.indexOf('const databaseUrl = process.env.DATABASE_URL'));
|
||||||
|
assert.match(
|
||||||
|
startup,
|
||||||
|
/if \(databaseUrl === undefined\) \{/,
|
||||||
|
'the server must branch on a missing DATABASE_URL (no database configured)',
|
||||||
|
);
|
||||||
|
const noDbBranch = startup.slice(startup.indexOf('if (databaseUrl === undefined) {'), startup.indexOf('} else {'));
|
||||||
|
assert.match(
|
||||||
|
noDbBranch,
|
||||||
|
/migrationsComplete = true;/,
|
||||||
|
'with no DATABASE_URL configured the server must report ready immediately (no migration run to wait for)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs every static criterion assertion against the committed server entrypoint. */
|
||||||
|
function assertReadySource(src) {
|
||||||
|
assertDriverBoundaryImports(src);
|
||||||
|
assertReadinessGate(src);
|
||||||
|
assertReadyAfterRun(src);
|
||||||
|
assertNoDatabaseUrlPath(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Boot helpers for the behavioral probes (Node type stripping, no build step)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the current Node executes TypeScript sources: `default` (>= 23.6, type
|
||||||
|
* stripping on by default), `strip-types-flag` (>= 22.6 via
|
||||||
|
* `--experimental-strip-types`) or `null` (cannot run .ts at all). The
|
||||||
|
* workspace pins engines.node to 24.x, where type stripping is stable.
|
||||||
|
*/
|
||||||
|
function tsExecMode() {
|
||||||
|
const [major, minor] = process.versions.node.split('.').map(Number);
|
||||||
|
if (major > 23 || (major === 23 && minor >= 6)) return 'default';
|
||||||
|
if (major === 22 && minor >= 6) return 'strip-types-flag';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reserves an ephemeral TCP port, then releases it for the child to bind. */
|
||||||
|
function reservePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const probe = createNetServer();
|
||||||
|
probe.once('error', reject);
|
||||||
|
probe.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = probe.address();
|
||||||
|
const port = typeof address === 'object' && address !== null ? address.port : 0;
|
||||||
|
probe.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boots the committed server source on `port` with the given env overrides
|
||||||
|
* (merged over `process.env`; `DATABASE_URL` is stripped unless explicitly
|
||||||
|
* provided). Returns `{ child, stderr, stdout }`; the child writes its
|
||||||
|
* stdout/stderr into closures for diagnostics.
|
||||||
|
*/
|
||||||
|
function bootServer(port, envOverrides = {}) {
|
||||||
|
const args =
|
||||||
|
tsExecMode() === 'strip-types-flag'
|
||||||
|
? ['--experimental-strip-types', SERVER_SRC]
|
||||||
|
: [SERVER_SRC];
|
||||||
|
const env = { ...process.env, PORT: String(port), ...envOverrides };
|
||||||
|
const child = spawn(process.execPath, args, {
|
||||||
|
cwd: REPO_ROOT,
|
||||||
|
env,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
stdout += String(chunk);
|
||||||
|
});
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
stderr += String(chunk);
|
||||||
|
});
|
||||||
|
return { child, stdout: () => stdout, stderr: () => stderr };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls `GET /health` until the server answers with ANY status, the child
|
||||||
|
* exits, or the deadline passes. Returns the fetch Response.
|
||||||
|
*/
|
||||||
|
async function waitForAnswer(port, child, stderr, deadlineMs = 10_000) {
|
||||||
|
const deadline = Date.now() + deadlineMs;
|
||||||
|
let lastError = '';
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (child.exitCode !== null) {
|
||||||
|
throw new Error(
|
||||||
|
`the server exited before answering GET /health (code ${child.exitCode}): ${stderr().trim()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await fetch(`http://127.0.0.1:${port}/health`, {
|
||||||
|
signal: AbortSignal.timeout(1_000),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err instanceof Error ? err.message : String(err);
|
||||||
|
await delay(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`GET /health did not answer within ${deadlineMs}ms (last error: ${lastError}; server stderr: ${stderr().trim()})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kills a booted child (SIGTERM, then SIGKILL if needed) and waits for exit. */
|
||||||
|
async function stopChild(child) {
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
await Promise.race([once(child, 'exit'), delay(2_000)]);
|
||||||
|
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Criterion tests — static assertions on the committed server entrypoint
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('the committed server entrypoint exists and gates readiness on the startup migration run', () => {
|
||||||
|
assert.ok(existsSync(path.join(REPO_ROOT, SERVER_SRC)), `committed ${SERVER_SRC} must exist`);
|
||||||
|
assertReadySource(read(SERVER_SRC));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the server runs migrations through the driver boundary, never importing pg/Kysely directly', () => {
|
||||||
|
assertDriverBoundaryImports(read(SERVER_SRC));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the /health route reports not-ready while migrations are pending and ready only after they finish', () => {
|
||||||
|
assertReadinessGate(read(SERVER_SRC));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the readiness flag flips to true only inside the startup migration run success handler', () => {
|
||||||
|
assertReadyAfterRun(read(SERVER_SRC));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with no DATABASE_URL configured the server reports ready immediately (no migration run to wait for)', () => {
|
||||||
|
assertNoDatabaseUrlPath(read(SERVER_SRC));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the app-readiness criterion is enforced in CI', () => {
|
||||||
|
// Picked up by the root test command (root `scripts.test` glob).
|
||||||
|
const scripts = JSON.parse(read('package.json')).scripts ?? {};
|
||||||
|
assert.equal(
|
||||||
|
scripts.test,
|
||||||
|
`node --test "${ROOT_TEST_GLOB}"`,
|
||||||
|
`root scripts.test must run the "${ROOT_TEST_GLOB}" glob so this suite runs with the rest`,
|
||||||
|
);
|
||||||
|
// And a dedicated CI job gates it on every PR.
|
||||||
|
const workflow = read('.gitea/workflows/ci.yml');
|
||||||
|
assert.ok(
|
||||||
|
workflow.includes(`node --test tests/app-readiness.test.mjs`),
|
||||||
|
`CI must run the app-readiness suite (job "${CI_JOB}") on every PR`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Deterministic behavioral probes (no database, no Docker) — the committed
|
||||||
|
// server is booted and probed over real HTTP
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** True when this Node can execute the committed `.ts` server source (>= 22.6, type stripping). */
|
||||||
|
const TS_STRIPPING = tsExecMode() !== null;
|
||||||
|
|
||||||
|
test('without DATABASE_URL the app reports ready immediately (deterministic probe)', { skip: !TS_STRIPPING }, async (t) => {
|
||||||
|
// The no-migration path (local non-container dev, E00-S01-T06): no
|
||||||
|
// DATABASE_URL is configured, so there is no startup migration run to wait
|
||||||
|
// for and GET /health must answer 200 {"status":"ok"} right away — keeping
|
||||||
|
// the E00-S02-T03 health endpoint working.
|
||||||
|
const port = await reservePort();
|
||||||
|
const { child, stdout, stderr } = bootServer(port); // DATABASE_URL stripped
|
||||||
|
try {
|
||||||
|
const response = await waitForAnswer(port, child, stderr);
|
||||||
|
assert.equal(
|
||||||
|
response.status,
|
||||||
|
200,
|
||||||
|
`GET /health without DATABASE_URL must answer 200 (got ${response.status}); server output: ${stdout().trim()} ${stderr().trim()}`,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
await response.json(),
|
||||||
|
{ status: 'ok' },
|
||||||
|
'without DATABASE_URL the app must report a healthy application ({"status":"ok"})',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await stopChild(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the app does not report ready while the migration run cannot complete (unreachable database)', { skip: !TS_STRIPPING }, async (t) => {
|
||||||
|
// "app does not report ready before migrations complete": with a
|
||||||
|
// DATABASE_URL that refuses connections, the startup migration run cannot
|
||||||
|
// complete, so the app must stay up but answer HTTP 503
|
||||||
|
// {"status":"not ready"} — never 200 — until the process is stopped.
|
||||||
|
const port = await reservePort();
|
||||||
|
const deadPort = await reservePort(); // reserved then released: nothing listens
|
||||||
|
const { child, stdout, stderr } = bootServer(port, {
|
||||||
|
DATABASE_URL: `postgres://eppp:eppp@127.0.0.1:${deadPort}/eppp`,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// Every answer over a ~2s window must be 503 not-ready, never 200.
|
||||||
|
let answered = 0;
|
||||||
|
for (let attempt = 0; attempt < 6; attempt += 1) {
|
||||||
|
const response = await waitForAnswer(port, child, stderr);
|
||||||
|
answered += 1;
|
||||||
|
assert.equal(
|
||||||
|
response.status,
|
||||||
|
503,
|
||||||
|
`GET /health must answer 503 while the migration run cannot complete (got ${response.status}); server output: ${stdout().trim()} ${stderr().trim()}`,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
await response.json(),
|
||||||
|
{ status: 'not ready' },
|
||||||
|
'the app must report not-ready ({"status":"not ready"}) while migrations are incomplete',
|
||||||
|
);
|
||||||
|
await delay(300);
|
||||||
|
}
|
||||||
|
assert.ok(answered >= 2, `the app must keep answering during the probe (answered ${answered} times)`);
|
||||||
|
assert.equal(
|
||||||
|
child.exitCode,
|
||||||
|
null,
|
||||||
|
'the app must stay up (not crash) while the migration run cannot complete — not-ready is a stable reported state',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
stdout() + stderr(),
|
||||||
|
/startup migration run failed; app stays not-ready/,
|
||||||
|
'the app must log that the startup migration run failed and it stays not-ready',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await stopChild(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Docker probe helpers (the real-stack probe skips cleanly without Docker)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function run(cmd, args, opts = {}) {
|
||||||
|
return spawnSync(cmd, args, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 600_000,
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the `docker` CLI with the Compose plugin is on PATH. */
|
||||||
|
function dockerComposeAvailable() {
|
||||||
|
try {
|
||||||
|
return run('docker', ['compose', 'version'], { timeout: 15_000 }).status === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a reachable Docker daemon exists. */
|
||||||
|
function dockerDaemonAvailable() {
|
||||||
|
try {
|
||||||
|
return run('docker', ['info'], { timeout: 15_000 }).status === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses `docker compose ps --format json` (JSON array or one object per line). */
|
||||||
|
function parsePsJson(stdout) {
|
||||||
|
const text = String(stdout).trim();
|
||||||
|
if (!text) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
return Array.isArray(parsed) ? parsed : [parsed];
|
||||||
|
} catch {
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => JSON.parse(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tolerant field lookup across compose ps JSON shapes. */
|
||||||
|
function field(container, ...names) {
|
||||||
|
for (const name of names) {
|
||||||
|
if (container[name] !== undefined) return container[name];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls `docker compose ps` until the db container of the given project
|
||||||
|
* reports healthy (or the deadline passes), so the probe never races a
|
||||||
|
* still-booting database.
|
||||||
|
*/
|
||||||
|
function waitForDbHealthy(project, deadlineMs = 60_000) {
|
||||||
|
const deadline = Date.now() + deadlineMs;
|
||||||
|
let last = '';
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const ps = run('docker', ['compose', '-p', project, 'ps', '--format', 'json'], {
|
||||||
|
cwd: REPO_ROOT,
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
if (ps.status === 0) {
|
||||||
|
last = ps.stdout;
|
||||||
|
const db = parsePsJson(ps.stdout).find((c) => field(c, 'Service', 'service') === 'db');
|
||||||
|
if (db && /healthy/i.test(String(field(db, 'Health', 'health') ?? ''))) return;
|
||||||
|
}
|
||||||
|
run(process.execPath, ['-e', 'setTimeout(() => {}, 1000)']); // db still booting — retry
|
||||||
|
}
|
||||||
|
throw new Error(`the db container did not become healthy within ${deadlineMs}ms (last ps: "${last.trim()}")`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Real-stack probe — the issue's test plan: "start with pending migrations
|
||||||
|
// and confirm readiness waits", against a real database
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DOCKER_COMPOSE = dockerComposeAvailable();
|
||||||
|
const DOCKER_DAEMON = dockerDaemonAvailable();
|
||||||
|
|
||||||
|
// An isolated compose project + non-default host port so this probe never
|
||||||
|
// collides with the other suites' projects/ports (ledger 55432, lock 55433,
|
||||||
|
// diagnostic 55434, compose-config's default project/5432).
|
||||||
|
const COMPOSE_PROJECT = 'eppp-readiness-probe';
|
||||||
|
const POSTGRES_HOST_PORT = '55435';
|
||||||
|
const DATABASE_URL = `postgres://eppp:eppp@127.0.0.1:${POSTGRES_HOST_PORT}/eppp`;
|
||||||
|
|
||||||
|
test('the app reports not-ready while the migration run is pending and ready only after it finishes (real stack)', { skip: !DOCKER_COMPOSE || !DOCKER_DAEMON || !TS_STRIPPING }, async (t) => {
|
||||||
|
// The issue's test plan: "start with pending migrations and confirm
|
||||||
|
// readiness waits". The probe starts the committed compose `db` service
|
||||||
|
// (its own project + host port), creates the migration ledger, then holds
|
||||||
|
// an ACCESS EXCLUSIVE lock on the ledger from a background psql session so
|
||||||
|
// the app's startup migration run (ledger.ensure/applied) is genuinely
|
||||||
|
// pending. It boots the committed server against that database and asserts
|
||||||
|
// GET /health answers 503 {"status":"not ready"} while the run is blocked,
|
||||||
|
// then — once the lock releases and the run completes — answers 200
|
||||||
|
// {"status":"ok"} and logs the completed run. Rollback: drop the ledger
|
||||||
|
// and `docker compose -p eppp-readiness-probe down -v` (issue rollback
|
||||||
|
// note: revert the readiness gating logic).
|
||||||
|
const composeEnv = { ...process.env, POSTGRES_PORT: POSTGRES_HOST_PORT };
|
||||||
|
const compose = (args, opts = {}) =>
|
||||||
|
run('docker', ['compose', '-p', COMPOSE_PROJECT, ...args], { cwd: REPO_ROOT, env: composeEnv, ...opts });
|
||||||
|
const execPsql = (args, opts = {}) =>
|
||||||
|
compose(['exec', '-T', 'db', 'psql', '-U', 'eppp', '-d', 'eppp', ...args], opts);
|
||||||
|
|
||||||
|
let app;
|
||||||
|
let lockHolder;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const up = compose(['up', '-d', 'db'], { timeout: 180_000 });
|
||||||
|
assert.equal(
|
||||||
|
up.status,
|
||||||
|
0,
|
||||||
|
`"docker compose up -d db" must exit 0:\n${(up.stdout || '')}\n${(up.stderr || '')}`.trim(),
|
||||||
|
);
|
||||||
|
waitForDbHealthy(COMPOSE_PROJECT);
|
||||||
|
|
||||||
|
// Clean slate (also recovers from a previously interrupted run): drop any
|
||||||
|
// leftover ledger, then create it so the app's migration run has a ledger
|
||||||
|
// to read (the run's ledger.ensure() is then a no-op and its
|
||||||
|
// ledger.applied() SELECT is what blocks behind the lock below).
|
||||||
|
const clean = execPsql([
|
||||||
|
'-v', 'ON_ERROR_STOP=1',
|
||||||
|
'-c',
|
||||||
|
'DROP TABLE IF EXISTS schema_migrations; ' +
|
||||||
|
'CREATE TABLE schema_migrations (version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now());',
|
||||||
|
]);
|
||||||
|
assert.equal(
|
||||||
|
clean.status,
|
||||||
|
0,
|
||||||
|
`creating the migration ledger must succeed:\n${(clean.stdout || '')}\n${(clean.stderr || '')}`.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hold an ACCESS EXCLUSIVE lock on the ledger in a background psql
|
||||||
|
// session (BEGIN; LOCK; SELECT pg_sleep(30) keeps the transaction — and
|
||||||
|
// the lock — open). The app's migration run blocks on this lock, so the
|
||||||
|
// run is deterministically "pending" while we assert not-ready.
|
||||||
|
lockHolder = spawn(
|
||||||
|
'docker',
|
||||||
|
[
|
||||||
|
'compose', '-p', COMPOSE_PROJECT, 'exec', '-T', 'db',
|
||||||
|
'psql', '-U', 'eppp', '-d', 'eppp', '-v', 'ON_ERROR_STOP=1',
|
||||||
|
'-c', 'BEGIN; LOCK TABLE schema_migrations IN ACCESS EXCLUSIVE MODE; SELECT pg_sleep(30);',
|
||||||
|
],
|
||||||
|
{ cwd: REPO_ROOT, env: composeEnv, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||||
|
);
|
||||||
|
let holderErr = '';
|
||||||
|
lockHolder.stderr.on('data', (chunk) => {
|
||||||
|
holderErr += String(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait until the AccessExclusiveLock is actually held (deterministic, so
|
||||||
|
// the app boots into a genuinely blocked run).
|
||||||
|
const lockDeadline = Date.now() + 15_000;
|
||||||
|
let lockHeld = false;
|
||||||
|
while (Date.now() < lockDeadline) {
|
||||||
|
const held = execPsql([
|
||||||
|
'-tA',
|
||||||
|
'-c',
|
||||||
|
"SELECT count(*) FROM pg_locks WHERE locktype = 'relation' AND relation = 'schema_migrations'::regclass AND mode = 'AccessExclusiveLock';",
|
||||||
|
]);
|
||||||
|
if (held.status === 0 && held.stdout.trim() === '1') {
|
||||||
|
lockHeld = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
run(process.execPath, ['-e', 'setTimeout(() => {}, 500)']);
|
||||||
|
}
|
||||||
|
assert.ok(lockHeld, `the probe must hold the ACCESS EXCLUSIVE lock on the ledger (holder stderr: "${holderErr.trim()}")`);
|
||||||
|
|
||||||
|
// Boot the committed server against the probe database.
|
||||||
|
const port = await reservePort();
|
||||||
|
app = bootServer(port, { DATABASE_URL });
|
||||||
|
|
||||||
|
// "app does not report ready before migrations complete": while the run
|
||||||
|
// is blocked, every /health answer must be 503 {"status":"not ready"}.
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
const response = await waitForAnswer(port, app.child, app.stderr);
|
||||||
|
assert.equal(
|
||||||
|
response.status,
|
||||||
|
503,
|
||||||
|
`GET /health must answer 503 while the migration run is pending (got ${response.status}); server output: ${app.stdout().trim()} ${app.stderr().trim()}`,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
await response.json(),
|
||||||
|
{ status: 'not ready' },
|
||||||
|
'the app must report not-ready ({"status":"not ready"}) while migrations are pending',
|
||||||
|
);
|
||||||
|
await delay(300);
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
app.child.exitCode,
|
||||||
|
null,
|
||||||
|
'the app must stay up (not crash) while the migration run is pending',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Release the lock: the psql session ends, its transaction rolls back and
|
||||||
|
// the ACCESS EXCLUSIVE lock is gone — the app's migration run completes.
|
||||||
|
lockHolder.kill('SIGTERM');
|
||||||
|
await Promise.race([once(lockHolder, 'exit'), delay(2_000)]);
|
||||||
|
if (lockHolder.exitCode === null && lockHolder.signalCode === null) lockHolder.kill('SIGKILL');
|
||||||
|
lockHolder = null;
|
||||||
|
|
||||||
|
// "readiness is reported only after migrations finish": once the run
|
||||||
|
// completes, /health must answer 200 {"status":"ok"}.
|
||||||
|
const readyDeadline = Date.now() + 15_000;
|
||||||
|
let ready = null;
|
||||||
|
while (Date.now() < readyDeadline) {
|
||||||
|
const response = await waitForAnswer(port, app.child, app.stderr);
|
||||||
|
if (response.status === 200) {
|
||||||
|
ready = await response.json();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
response.status,
|
||||||
|
503,
|
||||||
|
`GET /health must stay 503 only while the run is pending (got ${response.status})`,
|
||||||
|
);
|
||||||
|
await delay(200);
|
||||||
|
}
|
||||||
|
assert.ok(ready, `the app must report ready after the migration run completes (server output: ${app.stdout().trim()} ${app.stderr().trim()})`);
|
||||||
|
assert.deepEqual(ready, { status: 'ok' }, 'the app must report a healthy application ({"status":"ok"}) after migrations finish');
|
||||||
|
|
||||||
|
// The readiness flip must be attributable to a completed migration run:
|
||||||
|
// the app logs the completed run, and the migration ledger still exists.
|
||||||
|
assert.match(
|
||||||
|
app.stdout() + app.stderr(),
|
||||||
|
/startup migration run complete \(applied 0, skipped 0\)/,
|
||||||
|
`the app must log the completed startup migration run (got: ${app.stdout().trim()} ${app.stderr().trim()})`,
|
||||||
|
);
|
||||||
|
const ledgerClass = execPsql(['-tA', '-c', "SELECT to_regclass('public.schema_migrations');"]);
|
||||||
|
assert.equal(
|
||||||
|
ledgerClass.status,
|
||||||
|
0,
|
||||||
|
`ledger existence query must succeed:\n${(ledgerClass.stdout || '')}\n${(ledgerClass.stderr || '')}`.trim(),
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
ledgerClass.stdout,
|
||||||
|
/schema_migrations/,
|
||||||
|
'the migration ledger must exist after the startup migration run',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (app) await stopChild(app.child);
|
||||||
|
if (lockHolder) {
|
||||||
|
lockHolder.kill('SIGKILL');
|
||||||
|
await Promise.race([once(lockHolder, 'exit'), delay(1_000)]);
|
||||||
|
}
|
||||||
|
// Rollback note from the issue: revert the readiness gating logic — here,
|
||||||
|
// reset migration state and tear down the isolated project.
|
||||||
|
try {
|
||||||
|
execPsql(['-c', 'DROP TABLE IF EXISTS schema_migrations;']);
|
||||||
|
} catch {
|
||||||
|
// container may already be gone — the compose down below still cleans up
|
||||||
|
}
|
||||||
|
compose(['down', '-v'], { timeout: 120_000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Non-vacuous probes — the assertions above really do fail on violations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('removing the 503 not-ready branch makes the readiness-gate criterion fail (mutation probe)', () => {
|
||||||
|
const src = read(SERVER_SRC);
|
||||||
|
const withoutNotReady = src.replace(/ } else \{\n sendJson\(res, 503, NOT_READY_PAYLOAD\);\n \}\n/, ' }\n');
|
||||||
|
assert.notEqual(withoutNotReady, src, 'the mutation must actually remove the 503 branch');
|
||||||
|
assert.throws(() => assertReadinessGate(withoutNotReady), /503/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('answering 200 in the not-ready state makes the gate criterion fail (mutation probe)', () => {
|
||||||
|
const src = read(SERVER_SRC);
|
||||||
|
const notReady200 = src.replace('sendJson(res, 503, NOT_READY_PAYLOAD)', 'sendJson(res, 200, NOT_READY_PAYLOAD)');
|
||||||
|
assert.notEqual(notReady200, src, 'the mutation must actually change the not-ready status code');
|
||||||
|
assert.throws(() => assertReadinessGate(notReady200), /503/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replacing the gate with an unconditional healthy answer fails the gate criterion (mutation probe)', () => {
|
||||||
|
const src = read(SERVER_SRC);
|
||||||
|
const noGate = src.replace(/if \(migrationsComplete\) \{/, 'if (true) {');
|
||||||
|
assert.notEqual(noGate, src, 'the mutation must actually bypass the readiness flag');
|
||||||
|
assert.throws(() => assertReadinessGate(noGate), /migrationsComplete/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing the not-ready payload fails the not-ready-report criterion (mutation probe)', () => {
|
||||||
|
const src = read(SERVER_SRC);
|
||||||
|
const healthyNotReady = src.replace("status: 'not ready'", "status: 'ok'");
|
||||||
|
assert.notEqual(healthyNotReady, src, 'the mutation must actually change the not-ready payload');
|
||||||
|
assert.throws(() => assertReadinessGate(healthyNotReady), /not ready/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flipping the readiness flag before the migration run fails the readiness-only-after criterion (mutation probe)', () => {
|
||||||
|
const src = read(SERVER_SRC);
|
||||||
|
const earlyFlip = src.replace(
|
||||||
|
/const runner = new MigrationRunner\(pool, MIGRATIONS, new MigrationLedger\(pool\)\);\n runner/,
|
||||||
|
'migrationsComplete = true;\n const runner = new MigrationRunner(pool, MIGRATIONS, new MigrationLedger(pool));\n runner',
|
||||||
|
);
|
||||||
|
assert.notEqual(earlyFlip, src, 'the mutation must actually flip the flag before the run');
|
||||||
|
assert.throws(() => assertReadyAfterRun(earlyFlip), /only after/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dropping the migration run fails the readiness-after-run criterion (mutation probe)', () => {
|
||||||
|
const src = read(SERVER_SRC);
|
||||||
|
const noRun = src.replace(/ runner\n \.run\(\)\n/, '');
|
||||||
|
assert.notEqual(noRun, src, 'the mutation must actually remove the runner.run() call');
|
||||||
|
assert.throws(() => assertReadyAfterRun(noRun), /runner\.run\(\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('importing pg directly instead of the driver boundary fails the isolation criterion (mutation probe)', () => {
|
||||||
|
const src = read(SERVER_SRC);
|
||||||
|
const directPg = src.replace(
|
||||||
|
"import { Pool } from '@personal-blog/database-postgres';",
|
||||||
|
"import { Pool } from 'pg';",
|
||||||
|
);
|
||||||
|
assert.notEqual(directPg, src, 'the mutation must actually replace the boundary import');
|
||||||
|
assert.throws(() => assertDriverBoundaryImports(directPg), /must import the pool from the driver boundary/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing the no-DATABASE_URL ready path fails the no-migration criterion (mutation probe)', () => {
|
||||||
|
const src = read(SERVER_SRC);
|
||||||
|
const withoutNoDb = src.replace(/if \(databaseUrl === undefined\) \{\n[\s\S]*?\} else \{/, '} else {');
|
||||||
|
assert.notEqual(withoutNoDb, src, 'the mutation must actually remove the no-DATABASE_URL branch');
|
||||||
|
assert.throws(() => assertNoDatabaseUrlPath(withoutNoDb), /must branch on a missing DATABASE_URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a placeholder entrypoint (no server at all) fails the readiness-source criterion (mutation probe)', () => {
|
||||||
|
assert.throws(() => assertReadySource('export {};\n'), /driver boundary/);
|
||||||
|
});
|
||||||
@@ -509,15 +509,20 @@ test('docker compose up -d starts the database and application containers', { sk
|
|||||||
`the "app" container must stay running after "docker compose up -d" (state: "${appState}") — since T03 the server serves the health endpoint and must not exit`,
|
`the "app" container must stay running after "docker compose up -d" (state: "${appState}") — since T03 the server serves the health endpoint and must not exit`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// T03: the app serves the health endpoint — HTTP smoke test against the
|
// T03 + E00-S03-T06: the app serves the health endpoint — HTTP smoke test
|
||||||
// endpoint inside the app container (no host-port dependency), polling
|
// against the endpoint inside the app container (no host-port dependency),
|
||||||
// until it answers or times out.
|
// polling until it answers or times out. Since T06 the endpoint is the
|
||||||
|
// readiness probe: it answers 503 {"status":"not ready"} while the startup
|
||||||
|
// migration run is in flight and 200 {"status":"ok"} only after it
|
||||||
|
// completes, so the probe treats a 503 (and a connection failure) as
|
||||||
|
// "still starting — retry" and fail-fasts only on a definitive non-2xx
|
||||||
|
// answer.
|
||||||
let healthOutput = '';
|
let healthOutput = '';
|
||||||
let healthOk = false;
|
let healthOk = false;
|
||||||
for (let attempt = 0; attempt < 30 && !healthOk; attempt += 1) {
|
for (let attempt = 0; attempt < 30 && !healthOk; attempt += 1) {
|
||||||
const probe = run('docker', ['compose', 'exec', '-T', 'app', 'node', '-e', `
|
const probe = run('docker', ['compose', 'exec', '-T', 'app', 'node', '-e', `
|
||||||
fetch('http://127.0.0.1:3000/health')
|
fetch('http://127.0.0.1:3000/health')
|
||||||
.then(async (res) => { console.log(res.status, await res.text()); process.exit(res.ok ? 0 : 1); })
|
.then(async (res) => { console.log(res.status, await res.text()); process.exit(res.ok ? 0 : res.status === 503 ? 2 : 1); })
|
||||||
.catch(() => process.exit(2));
|
.catch(() => process.exit(2));
|
||||||
`], { cwd: REPO_ROOT, timeout: 15_000 });
|
`], { cwd: REPO_ROOT, timeout: 15_000 });
|
||||||
const output = String(probe.stdout ?? '') + String(probe.stderr ?? '');
|
const output = String(probe.stdout ?? '') + String(probe.stderr ?? '');
|
||||||
|
|||||||
@@ -111,15 +111,23 @@ function reservePort() {
|
|||||||
/**
|
/**
|
||||||
* Boots the committed server source on `port`. Returns `{ child, stderr }`;
|
* Boots the committed server source on `port`. Returns `{ child, stderr }`;
|
||||||
* the child writes its stderr into the `stderr()` closure for diagnostics.
|
* the child writes its stderr into the `stderr()` closure for diagnostics.
|
||||||
|
*
|
||||||
|
* The child boots WITHOUT `DATABASE_URL` (it is stripped from the inherited
|
||||||
|
* env): since E00-S03-T06 the health endpoint is the readiness probe, and the
|
||||||
|
* no-DATABASE_URL path is the one with no startup migration run to wait for —
|
||||||
|
* the server reports ready immediately, so this smoke test stays deterministic
|
||||||
|
* and exercises exactly the committed no-migration readiness path.
|
||||||
*/
|
*/
|
||||||
function bootServer(port) {
|
function bootServer(port) {
|
||||||
const args =
|
const args =
|
||||||
tsExecMode() === 'strip-types-flag'
|
tsExecMode() === 'strip-types-flag'
|
||||||
? ['--experimental-strip-types', SERVER_SRC]
|
? ['--experimental-strip-types', SERVER_SRC]
|
||||||
: [SERVER_SRC];
|
: [SERVER_SRC];
|
||||||
|
const env = { ...process.env, PORT: String(port) };
|
||||||
|
delete env.DATABASE_URL;
|
||||||
const child = spawn(process.execPath, args, {
|
const child = spawn(process.execPath, args, {
|
||||||
cwd: REPO_ROOT,
|
cwd: REPO_ROOT,
|
||||||
env: { ...process.env, PORT: String(port) },
|
env,
|
||||||
stdio: ['ignore', 'ignore', 'pipe'],
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
});
|
});
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user