From 4f902accd77c1bd378e25780a0be204980913a44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Mon, 25 May 2026 22:49:16 +0200 Subject: [PATCH] fix(planner): fail-loud DATABASE_URL + dedicated /health pool Mirrors #422 (fail-loud DB URL) and #430 (health-check pool reuse) for the planner app, which still had: - two \`process.env.DATABASE_URL ?? \"postgres://trails:trails@localhost:5432/trails\"\` call sites that would silently boot a misconfigured prod against localhost - a /health handler that opened a fresh postgres client + connection on every probe (no pool, per-call TCP/TLS handshake) Both now route through @trails-cool/db's \`getDatabaseUrl()\` (refuses to start in production if unset / matches the dev default; E2E=true is the opt-out) and a module-level singleton client (max: 2, idle_timeout: 30). Full repo: pnpm typecheck, pnpm lint, pnpm test all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/planner/server.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/planner/server.ts b/apps/planner/server.ts index 03f8147..a8d6e86 100644 --- a/apps/planner/server.ts +++ b/apps/planner/server.ts @@ -10,7 +10,8 @@ import { join, extname, resolve } from "node:path"; import { setupYjsWebSocket } from "./app/lib/yjs-server.ts"; import { createBoss, startWorker } from "@trails-cool/jobs"; import { expireSessionsJob } from "./app/jobs/expire-sessions.ts"; -import postgres from "postgres"; +import { getDatabaseUrl } from "@trails-cool/db"; +import postgres, { type Sql } from "postgres"; // See apps/journal/server.ts for the SENTRY_DSN / SENTRY_DISABLED contract. const FLAGSHIP_PLANNER_SENTRY_DSN = @@ -75,17 +76,27 @@ async function handleMetrics(_req: IncomingMessage, res: ServerResponse): Promis const version = process.env.SENTRY_RELEASE ?? "dev"; +// Module-level singleton dedicated to /health (mirrors PR #430 for the +// journal). Previously a fresh client + connection was opened on every +// probe; under monitoring cadence that's a fresh handshake every few +// seconds. Kept separate from any future main-app pool so a starvation +// event on the app pool doesn't fail the liveness probe. +let healthClient: Sql | null = null; +function getHealthClient(): Sql { + if (!healthClient) { + healthClient = postgres(getDatabaseUrl(), { max: 2, idle_timeout: 30 }); + } + return healthClient; +} + async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise { - const client = postgres(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails", { max: 1 }); try { - await client`SELECT 1`; + await getHealthClient()`SELECT 1`; res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "ok", version, db: "connected" })); } catch { res.writeHead(503, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "degraded", version, db: "unreachable" })); - } finally { - await client.end(); } } @@ -127,7 +138,7 @@ server.listen(port, async () => { logger.info({ port }, "Planner server listening"); logger.info({ port, path: "/sync/:sessionId" }, "Yjs WebSocket available"); - const boss = createBoss(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails"); + const boss = createBoss(getDatabaseUrl()); await startWorker(boss, [expireSessionsJob]); logger.info("Background job worker started"); });