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>
119 lines
4.9 KiB
TypeScript
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 };
|