Files
alpenwerk-hr/lib/db/index.ts
Maximilian Stubhan 91b2b3406b Stop waiting on the network eleven times per page
The app got slower as pages grew, and the reason was not the queries. It
was their number.

A transaction is pinned to one connection, and a connection runs queries
one after another. Every Promise.all in a withUser block looked like
concurrency and was a queue. Measured against the real database: the
round trip is ~36 ms, ten trivial `select 1` over one connection take
343 ms, over ten connections 39 ms. Nothing here is slow — the whole
dashboard payload is under 200 kB, and every table is around a thousand
rows.

More connections is the wrong answer: the RLS session context is per
transaction, so parallel reads mean parallel transactions, and those
multiply the connections the database will grant. Fewer round trips
instead. Postgres will return each sub-select as its own JSON column of
one result.

Per page view, counting the transaction frame:

  shell (paid by every page)  10 → 4
  overview                    14 → 5
  employee file               14 → 7
  employee list                8 → 6

The overview plus its shell went from 24 round trips to 9 — about 860 ms
of pure waiting down to about 320 ms.

The one trap is documented where it bites: inside json_agg, Postgres
formats values itself and the driver's parsers (lib/db/pool.ts) never
see them. Dates, numerics and uuids come out identical; timestamptz does
not — "+00:00" where the driver gives "…Z". Timestamps are compared as
strings in lib/history.ts to decide what happened later, and those two
forms sort against each other wrongly. Every timestamptz in a bundled
query therefore goes through zeitstempel(), which was checked
character-for-character against the driver.

Four loaders moved out of their pages into lib/ so the number of round
trips can be measured without building a React tree, and so the new path
could be held against the old one field by field: same rows, same order,
same strings, for the overview and for four employee files chosen to
differ (with history, a chief, a planned entry, one with dependents).

withUser now counts the queries in each transaction and says so in
development past a threshold. Without that, this grows back: each new
tile brings its own query, and nobody notices until everybody does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 19:31:36 +02:00

119 lines
4.9 KiB
TypeScript

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 fragt seit der Umstellung nicht mehr Supabase,
// sondern `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<Schema>({
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<Schema>;
/**
* 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<T>(userId: string | null, fn: (tx: Tx) => Promise<T>): Promise<T> {
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<T>(fn: (tx: Tx) => Promise<T>): Promise<T> {
return withUser(null, fn);
}
/** Für Migrations- und Wartungsskripte, die ausserhalb einer Anfrage laufen. */
export async function closeDb(): Promise<void> {
await db.destroy();
}
export { sql };