|
| 1 | +# Health endpoint with dependency checks — design & implementation plan |
| 2 | + |
| 3 | +> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. |
| 4 | +
|
| 5 | +**Issue:** #54 |
| 6 | +**Goal:** Replace the static `{ status: 'ok' }` health endpoint in `setup-project.sh` with a real liveness check that pings the database, reports version and uptime, and returns `503` when a dependency is down. Apply to both Cloudflare Workers and Node.js templates and mirror in `examples/todo-api-cloudflare`. |
| 7 | + |
| 8 | +--- |
| 9 | + |
| 10 | +## Response shape |
| 11 | + |
| 12 | +Healthy (HTTP `200`): |
| 13 | + |
| 14 | +```json |
| 15 | +{ |
| 16 | + "status": "healthy", |
| 17 | + "version": "0.0.1", |
| 18 | + "uptime": 3600, |
| 19 | + "timestamp": "2026-04-08T12:00:00Z", |
| 20 | + "requestId": "01HXYZ...", |
| 21 | + "checks": { "database": "connected" } |
| 22 | +} |
| 23 | +``` |
| 24 | + |
| 25 | +Unhealthy (HTTP `503`): same shape with `status: "unhealthy"` and the failing check set to `"disconnected"`. |
| 26 | + |
| 27 | +`checks` is an object so future dependencies (Redis, S3, external API) drop in as `checks.redis: "connected"` without breaking consumers. Overall `status` is `"unhealthy"` if any check is `"disconnected"`. |
| 28 | + |
| 29 | +--- |
| 30 | + |
| 31 | +## Design decisions |
| 32 | + |
| 33 | +| Decision | Choice | Rationale | |
| 34 | +|---|---|---| |
| 35 | +| Uptime on Workers | `Date.now() - startTime` captured at module load | Honest about isolate semantics; zero cost; no extra bindings | |
| 36 | +| Version source | `APP_VERSION` env var (both templates) | One pattern across Workers and Node; deploys can override | |
| 37 | +| File layout | `src/routes/health.ts` + `src/db/ping.ts` | Matches existing route-extraction pattern (e.g. `routes/todos.ts`); keeps `index.ts` tidy | |
| 38 | +| DB ping timeout | 2 s default, `HEALTH_DB_TIMEOUT_MS` overrides | Well under k8s `livenessProbe` default (10 s) and Workers CPU budget | |
| 39 | +| Missing binding/env | Counts as `disconnected` | A config-level outage is still an outage from the prober's view | |
| 40 | +| Internal throw | Caught and returned as `503` | Health endpoint must never `500` — load balancers treat `5xx` as "needs restart" | |
| 41 | +| Node DB connection | New `src/db/client.ts` singleton; refactor `seed.ts` to import it | First feature to need a shared client; pays off for future routes too | |
| 42 | + |
| 43 | +--- |
| 44 | + |
| 45 | +## File tree (deltas, both templates unless noted) |
| 46 | + |
| 47 | +``` |
| 48 | +api/ |
| 49 | +├── src/ |
| 50 | +│ ├── index.ts # MODIFIED: mount healthRoutes |
| 51 | +│ ├── routes/ |
| 52 | +│ │ └── health.ts # NEW |
| 53 | +│ └── db/ |
| 54 | +│ ├── client.ts # NEW (Node only) |
| 55 | +│ ├── ping.ts # NEW (template-specific body) |
| 56 | +│ └── seed.ts # MODIFIED (Node only): import client |
| 57 | +├── tests/ |
| 58 | +│ └── unit/ |
| 59 | +│ └── health.test.ts # EXPANDED |
| 60 | +├── .dev.vars.example # Workers: + APP_VERSION, HEALTH_DB_TIMEOUT_MS |
| 61 | +├── .env.example # Node: + APP_VERSION, HEALTH_DB_TIMEOUT_MS |
| 62 | +└── wrangler.toml # Workers: APP_VERSION in [vars] |
| 63 | +``` |
| 64 | + |
| 65 | +--- |
| 66 | + |
| 67 | +## Task 1: Add Node `src/db/client.ts` shared singleton, refactor `seed.ts` |
| 68 | + |
| 69 | +**Setup-project.sh changes (Node branch only):** |
| 70 | + |
| 71 | +Add a `write_file "$API_DIR/src/db/client.ts"` block: |
| 72 | + |
| 73 | +```typescript |
| 74 | +import { drizzle } from 'drizzle-orm/postgres-js'; |
| 75 | +import postgres from 'postgres'; |
| 76 | +import * as schema from './schema.js'; |
| 77 | + |
| 78 | +const databaseUrl = process.env.DATABASE_URL; |
| 79 | +if (!databaseUrl) { |
| 80 | + throw new Error('DATABASE_URL environment variable is required'); |
| 81 | +} |
| 82 | + |
| 83 | +export const client = postgres(databaseUrl); |
| 84 | +export const db = drizzle(client, { schema }); |
| 85 | +``` |
| 86 | + |
| 87 | +Refactor `seed.ts` to import `client` and `db` from `./client.js` instead of constructing its own `postgres()` call. Keep `client.end()` at the end of `seed()`. |
| 88 | + |
| 89 | +**Verify:** generate a Node project, run `pnpm db:seed` — must connect and seed without errors. |
| 90 | + |
| 91 | +**Commit:** `refactor(template): extract shared postgres client for Node template` |
| 92 | + |
| 93 | +--- |
| 94 | + |
| 95 | +## Task 2: Add `src/db/ping.ts` to both templates |
| 96 | + |
| 97 | +**Workers branch:** |
| 98 | + |
| 99 | +```typescript |
| 100 | +import type { Context } from 'hono'; |
| 101 | +import postgres from 'postgres'; |
| 102 | + |
| 103 | +type Bindings = { HYPERDRIVE: Hyperdrive }; |
| 104 | + |
| 105 | +export async function pingDatabase(c: Context<{ Bindings: Bindings }>): Promise<'connected' | 'disconnected'> { |
| 106 | + if (!c.env.HYPERDRIVE?.connectionString) return 'disconnected'; |
| 107 | + const sql = postgres(c.env.HYPERDRIVE.connectionString, { max: 1, fetch_types: false }); |
| 108 | + try { |
| 109 | + await sql`SELECT 1`; |
| 110 | + return 'connected'; |
| 111 | + } catch { |
| 112 | + return 'disconnected'; |
| 113 | + } finally { |
| 114 | + await sql.end({ timeout: 1 }).catch(() => {}); |
| 115 | + } |
| 116 | +} |
| 117 | +``` |
| 118 | + |
| 119 | +**Node branch:** |
| 120 | + |
| 121 | +```typescript |
| 122 | +import { client } from './client.js'; |
| 123 | + |
| 124 | +export async function pingDatabase(): Promise<'connected' | 'disconnected'> { |
| 125 | + try { |
| 126 | + await client`SELECT 1`; |
| 127 | + return 'connected'; |
| 128 | + } catch { |
| 129 | + return 'disconnected'; |
| 130 | + } |
| 131 | +} |
| 132 | +``` |
| 133 | + |
| 134 | +**Commit:** `feat(template): add pingDatabase helper for health checks` |
| 135 | + |
| 136 | +--- |
| 137 | + |
| 138 | +## Task 3: Add `src/routes/health.ts` to both templates |
| 139 | + |
| 140 | +Common skeleton (Workers shown; Node omits the `c` arg to `pingDatabase()`): |
| 141 | + |
| 142 | +```typescript |
| 143 | +import { Hono } from 'hono'; |
| 144 | +import { pingDatabase } from '../db/ping'; |
| 145 | + |
| 146 | +const startTime = Date.now(); |
| 147 | + |
| 148 | +type Bindings = { |
| 149 | + APP_VERSION: string; |
| 150 | + HEALTH_DB_TIMEOUT_MS: string; |
| 151 | + HYPERDRIVE: Hyperdrive; |
| 152 | +}; |
| 153 | + |
| 154 | +export const healthRoutes = new Hono<{ Bindings: Bindings }>().get('/', async (c) => { |
| 155 | + const timeoutMs = Number(c.env.HEALTH_DB_TIMEOUT_MS ?? 2000); |
| 156 | + const timeout = new Promise<'disconnected'>((resolve) => |
| 157 | + setTimeout(() => resolve('disconnected'), timeoutMs), |
| 158 | + ); |
| 159 | + |
| 160 | + let database: 'connected' | 'disconnected'; |
| 161 | + try { |
| 162 | + database = await Promise.race([pingDatabase(c), timeout]); |
| 163 | + } catch { |
| 164 | + database = 'disconnected'; |
| 165 | + } |
| 166 | + |
| 167 | + const status = database === 'connected' ? 'healthy' : 'unhealthy'; |
| 168 | + const body = { |
| 169 | + status, |
| 170 | + version: c.env.APP_VERSION ?? 'unknown', |
| 171 | + uptime: Math.floor((Date.now() - startTime) / 1000), |
| 172 | + timestamp: new Date().toISOString(), |
| 173 | + requestId: c.get('requestId'), |
| 174 | + checks: { database }, |
| 175 | + }; |
| 176 | + return c.json(body, status === 'healthy' ? 200 : 503); |
| 177 | +}); |
| 178 | +``` |
| 179 | + |
| 180 | +**Wire into `index.ts`** (both templates): replace the inline `app.get('/health', ...)` with: |
| 181 | + |
| 182 | +```typescript |
| 183 | +import { healthRoutes } from './routes/health'; |
| 184 | +// ... |
| 185 | +app.route('/health', healthRoutes); |
| 186 | +``` |
| 187 | + |
| 188 | +**Commit:** `feat(template): replace static /health with dependency-checking handler` |
| 189 | + |
| 190 | +--- |
| 191 | + |
| 192 | +## Task 4: Update env/config templates |
| 193 | + |
| 194 | +**Workers (`.dev.vars.example`):** |
| 195 | + |
| 196 | +``` |
| 197 | +APP_VERSION=0.0.1 |
| 198 | +HEALTH_DB_TIMEOUT_MS=2000 |
| 199 | +``` |
| 200 | + |
| 201 | +**Workers (`wrangler.toml` `[vars]` block):** |
| 202 | + |
| 203 | +```toml |
| 204 | +[vars] |
| 205 | +ENVIRONMENT = "development" |
| 206 | +APP_VERSION = "0.0.1" |
| 207 | +HEALTH_DB_TIMEOUT_MS = "2000" |
| 208 | +``` |
| 209 | + |
| 210 | +**Node (`.env.example`):** |
| 211 | + |
| 212 | +``` |
| 213 | +APP_VERSION=0.0.1 |
| 214 | +HEALTH_DB_TIMEOUT_MS=2000 |
| 215 | +``` |
| 216 | + |
| 217 | +**Commit:** `chore(template): add APP_VERSION and HEALTH_DB_TIMEOUT_MS to env templates` |
| 218 | + |
| 219 | +--- |
| 220 | + |
| 221 | +## Task 5: Expand `tests/unit/health.test.ts` |
| 222 | + |
| 223 | +Test cases (both templates): |
| 224 | + |
| 225 | +1. **Healthy path** — `pingDatabase` mocked → `'connected'`. Asserts `status: 'healthy'`, `checks.database: 'connected'`, HTTP `200`. |
| 226 | +2. **Version from env** — `APP_VERSION=0.0.1` in test env. Asserts `body.version === '0.0.1'`. |
| 227 | +3. **Uptime numeric** — Asserts `typeof body.uptime === 'number'` and `body.uptime >= 0`. |
| 228 | +4. **Unhealthy when DB disconnected** — `pingDatabase` mocked → `'disconnected'`. Asserts `status: 'unhealthy'`, `checks.database: 'disconnected'`, HTTP `503`. |
| 229 | +5. **Unhealthy on timeout** — `pingDatabase` mocked to return a never-resolving promise; fake timers advance past `HEALTH_DB_TIMEOUT_MS`. Asserts `503`. |
| 230 | +6. **Unhealthy on thrown error** — `pingDatabase` mocked to throw. Asserts `503` (never `500`). |
| 231 | +7. **Preserves requestId + X-Request-Id header** — keeps the existing three assertions but updates the status assertion from `'ok'` → `'healthy'`. |
| 232 | + |
| 233 | +Use `vi.mock('../../src/db/ping')` to swap `pingDatabase`. No real postgres needed in CI. |
| 234 | + |
| 235 | +**Commit:** `test(template): cover health endpoint dependency status, timeout, and error paths` |
| 236 | + |
| 237 | +--- |
| 238 | + |
| 239 | +## Task 6: Mirror in `examples/todo-api-cloudflare` |
| 240 | + |
| 241 | +This example has its own `src/index.ts`, `src/routes/`, and `tests/unit/health.test.ts`. Apply Tasks 2, 3, and 5 to the example (its DB is D1, not Hyperdrive, so `pingDatabase` uses `c.env.DB.prepare('SELECT 1').first()` instead of `postgres()`). |
| 242 | + |
| 243 | +Update `wrangler.toml` `[vars]` and `.dev.vars.example` in the example accordingly. |
| 244 | + |
| 245 | +**Commit:** `feat(example): wire dependency-checking /health into todo-api-cloudflare` |
| 246 | + |
| 247 | +--- |
| 248 | + |
| 249 | +## Task 7: Smoke-test generated templates |
| 250 | + |
| 251 | +```bash |
| 252 | +# Node smoke test |
| 253 | +./scripts/setup-project.sh /tmp/nerva-health-node --node |
| 254 | +cd /tmp/nerva-health-node && pnpm install && pnpm typecheck && pnpm test |
| 255 | + |
| 256 | +# Workers smoke test |
| 257 | +./scripts/setup-project.sh /tmp/nerva-health-cf --cloudflare |
| 258 | +cd /tmp/nerva-health-cf && pnpm install && pnpm typecheck && pnpm test |
| 259 | +``` |
| 260 | + |
| 261 | +Both must pass typecheck and the health tests. If a generated project doesn't compile, fix the template — not the generated output. |
| 262 | + |
| 263 | +**Verify also:** existing CI matrix (`Node.js 20 Compatibility`, `Node.js 22 Compatibility`, `Check Markdown Links`, `Validate Structure`) passes on the PR. |
| 264 | + |
| 265 | +--- |
| 266 | + |
| 267 | +## Out of scope |
| 268 | + |
| 269 | +- Separate `/health/live` vs `/health/ready` (k8s-style split) — single endpoint matches the issue's example response. |
| 270 | +- External-service checks beyond DB — pattern is in place for future additions. |
| 271 | +- Auto-injecting `APP_VERSION` from `package.json` at build time — env var pattern is the contract; build-time substitution is a follow-up. |
0 commit comments