Files
alpenwerk-hr/lib/db/index.ts
Maximilian Stubhan b87c8ad64c
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m40s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Remove Supabase
The database moved to a container of our own; the platform is gone.
This takes out what was left of it — and, where the leftovers were load
bearing, moves rather than deletes.

Moved, not deleted:

  supabase/migrations/  -> db/migrations/      the schema's source of truth
  supabase/build-org.ts -> scripts/build-org.ts
  lib/supabase/types.ts -> lib/types.ts        52 import sites repointed

The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`,
and simply renaming the schema would have left the runner facing an empty
table: it would have called all 67 migrations pending and replayed them
against a database that is long since current. So the runner now creates
`migrationen.schema_migrations` and, once, copies the old rows across —
guarded so a second run does nothing and a fresh database skips it entirely.
Only then does migration 20260907100000 drop the old schema.

Deleted: the CLI config, the seed, the historical schema/function dumps
(nothing read them), scripts/umzug-von-supabase.sh (the move is done), and
both Supabase packages plus the CLI. Nothing in the application imported
them — the build now succeeds with no environment variables at all, which
is the proof.

Integration tests: six of them signed in through Supabase Auth and asserted
against the anon key and the service role. That model is gone, so the tests
were not portable — they are deleted. session-context and
employee-status-filter already ran on pg and are untouched; om-reporting is
ported to a direct connection because it guards a real risk (the reporting
line rule exists twice, once in SQL and once in TypeScript).

CI: the integration job started a Supabase stack. It now runs a postgres
service, applies deploy/db-init and every migration to an empty database —
that was the valuable part, and it still holds — then checks that a second
run is a no-op, which is what proves the bookkeeping works.

Docs: security-review.md audited a service-role key, a cookie adapter and
auth.users, none of which exist. Restating findings about removed components
would suggest today's system had been reviewed; it has not. It now records
what was removed and says a fresh review is due. data-model.md was already
marked obsolete and described the pre-OM schema; azure-migration.md was a
plan for a route not taken. Both deleted.

Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the
packages. Integration tests skip cleanly with no database. Migration SQL and
the runner are reviewed but NOT executed: no Docker here, and the old
instance no longer resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:43:22 +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 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<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 };