import "server-only"; import { AsyncLocalStorage } from "node:async_hooks"; import { Kysely, PostgresDialect, sql, type Transaction } from "kysely"; import { getPool } from "./pool"; import type { Schema } from "./schema"; // Der einzige Weg an die Datenbank. // // ═══ Warum das keine gewöhnliche Datenbankschicht ist ═══ // // Die Zugriffsrechte liegen in der Datenbank: 21 RLS-Policies rufen // is_hr_user() auf, und das liest // `current_setting('app.user_id')` — eine Sitzungsvariable. // // Sitzungsvariablen hängen an der *Verbindung*, nicht an der Anfrage. Und // Verbindungen kommen aus einem Pool. Wird die Variable ohne Transaktion // gesetzt, bleibt sie an der Verbindung kleben, und die nächste Anfrage, die // dieselbe Verbindung zieht, läuft mit der Kennung der vorherigen Person — // quer über Benutzer hinweg, in einer Personaldatenbank. // // Das ist die Art Fehler, die in keinem Test auffällt, den man nicht // absichtlich dafür schreibt (tests/integration/session-context.test.ts tut // genau das). Deshalb: // // 1. Die Kysely-Instanz wird **nicht exportiert**. Wer abfragen will, muss // durch withUser() — und das öffnet immer eine Transaktion. // 2. `set_config(..., true)` — das dritte Argument bedeutet // transaktionslokal. Mit `false` wäre die ganze Vorsichtsmassnahme // wirkungslos. // 3. Eine ESLint-Regel verbietet den Import von `pg` und `./pool` // ausserhalb dieses Verzeichnisses. // // Zusätzlich verbindet sich die Anwendung mit einer Datenbankrolle **ohne** // BYPASSRLS. Fehlt der Kontext trotz allem, liefern die Policies nichts // zurück — nicht alles. // Der Pool wird als Funktion übergeben, nicht als fertige Instanz: Kysely // ruft sie erst bei der ersten Abfrage auf. So verlangt der Import dieses // Moduls noch keine Zugangsdaten — siehe getPool(). // ═══ Wie viele Rundreisen eine Anfrage kostet ═══ // // Eine Transaktion hängt an einer Verbindung, und über eine Verbindung laufen // Abfragen nacheinander — auch die in einem Promise.all. Bei rund 36 ms // Umlaufzeit zur Datenbank ist die Zahl der Abfragen deshalb *die* Kennzahl // für die Ladezeit einer Seite, und zwar eine, die man nicht schätzen muss. // // Sie wird darum mitgezählt und im Entwicklungsbetrieb gemeldet, sobald eine // Transaktion viele davon braucht. Ohne diese Meldung wächst so etwas // unbemerkt: jede neue Kachel bringt ihre eigene Abfrage mit, und dass die // Seite langsamer wird, merkt man erst, wenn es alle merken. const zaehler = new AsyncLocalStorage<{ abfragen: number }>(); export function zaehleAbfragen(): { abfragen: number } | undefined { return zaehler.getStore(); } /** Ab wann eine Transaktion im Entwicklungsbetrieb gemeldet wird. */ const MELDESCHWELLE = Number(process.env.DB_QUERY_WARN ?? 6); const db = new Kysely({ dialect: new PostgresDialect({ pool: async () => getPool() }), log: (event) => { const store = zaehler.getStore(); if (store) store.abfragen++; if (event.level === "error") console.error("Abfrage fehlgeschlagen:", event.error); }, }); export type Tx = Transaction; /** * Führt `fn` im Namen der angegebenen Person aus. * * `userId` ist die app_users.id. Für nicht angemeldete Zugriffe null — dann * greift keine Policy und es kommt nichts zurück, was auch richtig ist. */ export async function withUser(userId: string | null, fn: (tx: Tx) => Promise): Promise { const stand = { abfragen: 0 }; const start = performance.now(); try { return await zaehler.run(stand, () => db.transaction().execute(async (tx) => { // Erste Anweisung der Transaktion, vor allem anderen. await sql`select set_config('app.user_id', ${userId ?? ""}, true)`.execute(tx); return fn(tx); }) ); } finally { // Nur im Entwicklungsbetrieb: in der Produktion gehörte das in die // Ablaufverfolgung, nicht auf die Konsole. if (process.env.NODE_ENV !== "production" && stand.abfragen > MELDESCHWELLE) { console.warn( `[db] ${stand.abfragen} Abfragen in einer Transaktion, ${Math.round(performance.now() - start)} ms — ` + `sie laufen nacheinander über eine Verbindung. Bündeln: siehe lib/db/json.ts.` ); } } } /** * Für Abläufe ohne angemeldete Person — heute nur der nächtliche Lauf für * fällige Änderungen. * * Bewusst kein privilegierter Zugang: die Verbindung benutzt dieselbe Rolle * ohne BYPASSRLS. Was hier laufen darf, muss als SECURITY-DEFINER-Funktion * in der Datenbank stehen und dort selbst prüfen, was es tut. Ein * Dienstschlüssel, der RLS aushebelt, existiert nicht mehr. */ export async function asSystem(fn: (tx: Tx) => Promise): Promise { return withUser(null, fn); } /** Für Migrations- und Wartungsskripte, die ausserhalb einer Anfrage laufen. */ export async function closeDb(): Promise { await db.destroy(); } export { sql };