The app died with "max clients reached in session mode - pool_size: 15". Two causes, both real, neither visible without a live database. The connection string pointed at the pooler's session mode, which pins one backend per client and caps at 15 on Supabase. Every query here already runs inside a transaction and the session context is set transaction-locally, so transaction mode is not a workaround but the mode this design was written for. Verified: 20 concurrent transactions, all 852 rows, 0.4s — and still nothing without a session context. The second cause was the dev server. Next.js re-evaluates changed modules, so a module-local `let` was empty afterwards while the previous pool stayed alive holding its connections. An afternoon of editing exhausted the quota. The pool now hangs off globalThis, which is inert in production where nothing reloads. Documented in .env.example and DEPLOYMENT.md, because a deployment that picks port 5432 fails this way under load and not before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
103 lines
4.9 KiB
TypeScript
103 lines
4.9 KiB
TypeScript
import "server-only";
|
|
import { Pool, types } from "pg";
|
|
|
|
// ═══ Wie Werte aus der Datenbank ankommen ════════════════════════
|
|
//
|
|
// Der Wechsel des Zugriffswegs hat hier eine Falle hinterlassen, die kein
|
|
// Typprüfer und keiner der Tests fangen konnte.
|
|
//
|
|
// Die alte API-Schicht lieferte JSON: ein `date` kam als "2026-08-03" an,
|
|
// ein `numeric` als Zahl. Genau so steht es in lib/supabase/types.ts, und
|
|
// darauf baut die gesamte Anwendung — Sortierungen mit localeCompare,
|
|
// Vergleiche wie `entry_date <= stichtag`, das Ableiten des Status.
|
|
//
|
|
// Der `pg`-Treiber macht es anders herum: aus `date` wird ein Date-Objekt,
|
|
// aus `numeric` eine Zeichenkette. Die Deklarationen blieben dabei
|
|
// unverändert gültig — sie beschreiben ja nur, was der Code *glaubt*. Der
|
|
// Fehler zeigt sich erst zur Laufzeit, und im günstigen Fall als Absturz
|
|
// („a.date.localeCompare is not a function"). Im ungünstigen Fall gar
|
|
// nicht: ein Datumsvergleich zwischen Date und Zeichenkette wirft nicht, er
|
|
// liefert bloss das falsche Ergebnis.
|
|
//
|
|
// Deshalb wird der Treiber hier auf die Form zurückgestellt, die die Typen
|
|
// beschreiben. Das ist die kleinere und ehrlichere Änderung, als 49
|
|
// Abfragestellen umzuschreiben.
|
|
//
|
|
// Nebenbei löst es ein zweites Problem: `date` ist ein Kalendertag ohne
|
|
// Zeitzone. Als Date-Objekt bekäme er eine — Mitternacht in der Zone des
|
|
// Servers —, und ein Geburtsdatum verschöbe sich beim Formatieren um einen
|
|
// Tag. Dieselbe Klasse von Fehler wie im Seed.
|
|
types.setTypeParser(1082, (v) => v); // date → "YYYY-MM-DD", unverändert
|
|
types.setTypeParser(1184, (v) => new Date(v).toISOString()); // timestamptz → ISO-8601 mit Z
|
|
types.setTypeParser(1114, (v) => new Date(v + "Z").toISOString()); // timestamp ohne Zone
|
|
types.setTypeParser(1700, (v) => Number(v)); // numeric → Zahl
|
|
|
|
// Bewusst *nicht* umgestellt: int8 (bigint). Es kommt nur aus count() und
|
|
// wird überall mit Number() gelesen; als Zahl geparst verlöre es jenseits
|
|
// von 2^53 stillschweigend an Genauigkeit.
|
|
|
|
// Die einzige Stelle im Projekt, die `pg` importieren darf.
|
|
//
|
|
// Der Grund steht in lib/db/index.ts: eine Abfrage ausserhalb von withUser()
|
|
// läuft ohne Sitzungskontext und damit — je nachdem, was die vorherige
|
|
// Anfrage auf derselben gepoolten Verbindung hinterlassen hat — im Namen
|
|
// einer fremden Person. Deshalb wird der Pool nicht exportiert, sondern nur
|
|
// die Kysely-Instanz, die ihn benutzt, und eine ESLint-Regel verbietet den
|
|
// Import von `pg` und von dieser Datei überall sonst.
|
|
|
|
// Der Pool hängt am globalen Objekt, nicht nur am Modul.
|
|
//
|
|
// Im Entwicklungsbetrieb lädt Next.js geänderte Module neu. Ein modul-lokales
|
|
// `let` wäre danach leer, der alte Pool aber weiterhin am Leben — mit seinen
|
|
// Verbindungen. Nach ein paar Bearbeitungen ist das Kontingent des Anbieters
|
|
// aufgebraucht, und die Anwendung antwortet nur noch mit „max clients
|
|
// reached". Genau so ist sie hier stehengeblieben.
|
|
//
|
|
// In der Produktion gibt es kein Neuladen; dort ist die Zeile wirkungslos.
|
|
const globalForPool = globalThis as typeof globalThis & { __alpenwerkPool?: Pool };
|
|
|
|
let instance: Pool | undefined = globalForPool.__alpenwerkPool;
|
|
|
|
/**
|
|
* Der Verbindungspool — erst beim ersten Zugriff angelegt, nicht beim Import.
|
|
*
|
|
* Der Unterschied ist nicht kosmetisch: `next build` importiert jedes Route-
|
|
* Modul, um die Seitendaten einzusammeln. Entstünde der Pool dabei, bräuchte
|
|
* schon der Bau Zugangsdaten zur Datenbank — ein Container-Abbild liesse sich
|
|
* in einer Baustrecke ohne Produktionsgeheimnisse nicht mehr erzeugen.
|
|
*/
|
|
export function getPool(): Pool {
|
|
if (instance) return instance;
|
|
|
|
const connectionString = process.env.DATABASE_URL;
|
|
if (!connectionString) {
|
|
throw new Error(
|
|
"DATABASE_URL fehlt. Erwartet wird ein PostgreSQL-Verbindungsstring — " +
|
|
"die Anwendung spricht direkt mit der Datenbank, nicht über eine API-Schicht."
|
|
);
|
|
}
|
|
|
|
instance = new Pool({
|
|
connectionString,
|
|
// Der Standard sind 10; bei serverseitigem Rendering hängt an jeder
|
|
// Anfrage genau eine Transaktion, und mehr Verbindungen als die Datenbank
|
|
// zulässt bringen nur Wartezeit an einer anderen Stelle.
|
|
max: Number(process.env.DATABASE_POOL_MAX ?? 10),
|
|
// Eine Anfrage, die länger braucht, ist kaputt und soll das melden statt
|
|
// eine Verbindung zu belegen.
|
|
statement_timeout: 20_000,
|
|
idle_in_transaction_session_timeout: 20_000,
|
|
connectionTimeoutMillis: 10_000,
|
|
// Verwaltete Anbieter (Azure, RDS, Supabase) verlangen TLS; lokal nicht.
|
|
ssl: process.env.DATABASE_SSL === "false" ? undefined : { rejectUnauthorized: false },
|
|
});
|
|
|
|
// Ein Fehler auf einer Leerlaufverbindung beendet sonst den Prozess.
|
|
instance.on("error", (err) => {
|
|
console.error("Unerwarteter Fehler auf einer Leerlaufverbindung:", err);
|
|
});
|
|
|
|
globalForPool.__alpenwerkPool = instance;
|
|
return instance;
|
|
}
|