Erster Schritt weg von Supabase hin zu "läuft auf jedem PostgreSQL".
Gemessen sitzt die Kopplung nicht dort, wo der Begriff "Supabase-Projekt"
sie vermuten lässt: das Schema ist reines PostgreSQL, und von 58 RLS-Policies
rufen nur fünf auth.uid() direkt auf. Die übrigen 53 gehen über is_hr_user().
Diese eine Funktion ist die Brücke — wird sie umgelegt, folgt der Rest.
Die Migration legt sie um. app_current_user_id() liest jetzt zuerst
current_setting('app.user_id') und fällt nur ersatzweise auf auth.uid()
zurück. Deshalb plpgsql statt language sql: eine SQL-Funktion wird beim
Anlegen geparst, und auth.uid() gibt es auf einem gewöhnlichen PostgreSQL
nicht — die Migration liesse sich dort gar nicht erst anwenden. Der
Ausnahmeblock fängt das ab, und damit läuft dieselbe Migration auf beiden
Systemen. Der Rückfall verschwindet mit der Abschlussmigration.
Dazu app_users als Nachfolger von auth.users, external_id ist die oid des
Anbieters statt der E-Mail: eine Namensänderung darf kein zweites Konto
erzeugen.
Die neue Zugriffsschicht ist Kysely auf einem pg-Pool. Was daran zählt, ist
nicht der Query-Builder, sondern was er verhindert:
- Die Kysely-Instanz wird nicht exportiert. Wer abfragen will, geht durch
withUser() — und das öffnet immer eine Transaktion.
- set_config(..., true) ist transaktionslokal. Ohne das dritte Argument
bliebe die Kennung an der gepoolten Verbindung kleben und die nächste
Anfrage liefe im Namen der vorherigen Person. In einer Personaldatenbank.
- Eine ESLint-Regel verbietet den Import von pg und von lib/db/pool
ausserhalb von lib/db. Nachgewiesen: eine Testdatei mit beiden Importen
erzeugt zwei Fehler.
- Einen privilegierten Zugang gibt es nicht mehr. asSystem() benutzt
dieselbe Rolle ohne BYPASSRLS; was ohne angemeldete Person laufen darf,
muss als SECURITY-DEFINER-Funktion in der Datenbank stehen.
tests/integration/session-context.test.ts läuft gegen einen Pool mit genau
einer Verbindung — sonst träfe er die Lücke mal und mal nicht. Er prüft, dass
nach Commit *und* nach Rollback nichts an der Verbindung zurückbleibt, und
belegt in einer Gegenprobe, dass eine Einstellung ohne Transaktion tatsächlich
hängen bleibt. Ein Sicherheitstest, der sich mangels DATABASE_URL selbst
überspringt, wäre schlimmer als keiner: in der CI schlägt schon das Fehlen
des Verbindungsstrings fehl.
Beim Schreiben der Migration stellte sich heraus, dass die Policies
hire_drafts_owner und saved_reports_owner heissen, nicht _own. Mit dem
geratenen Namen hätte drop policy nichts getroffen und create policy wäre mit
"already exists" abgebrochen.
Typecheck, Lint und 182 Tests sind grün. Die Anwendung läuft unverändert
weiter — sie benutzt die neue Schicht noch nicht.
50 lines
2.0 KiB
TypeScript
50 lines
2.0 KiB
TypeScript
import type { ColumnType } from "kysely";
|
|
import type { Database } from "@/lib/supabase/types";
|
|
|
|
// Die Tabellenform für Kysely, abgeleitet aus der bestehenden
|
|
// Schemabeschreibung — nicht daneben gestellt.
|
|
//
|
|
// Zwei Beschreibungen desselben Schemas driften auseinander, und die eine
|
|
// hier ist bereits gegen die Migrationen abgesichert: `npm run types:check`
|
|
// vergleicht lib/supabase/types.ts Spalte für Spalte mit
|
|
// supabase/migrations/*.sql und schlägt in der CI fehl, wenn etwas fehlt.
|
|
// Diese Ableitung erbt diese Absicherung.
|
|
//
|
|
// Die Datei heisst noch lib/supabase/types.ts, weil sie aus der Zeit stammt,
|
|
// als PostgREST der Zugriffsweg war. Sie beschreibt reines PostgreSQL und
|
|
// wird beim Entfernen der Supabase-Pakete lediglich umbenannt.
|
|
|
|
type Tables = Database["public"]["Tables"];
|
|
|
|
/**
|
|
* Row/Insert/Update einer Tabelle in Kyselys ColumnType-Form.
|
|
*
|
|
* Kysely braucht die drei Richtungen getrennt: was beim Lesen herauskommt,
|
|
* was beim Einfügen erlaubt ist (Spalten mit Vorgabewert dürfen fehlen) und
|
|
* was beim Aktualisieren erlaubt ist.
|
|
*/
|
|
type Columns<T extends keyof Tables> = {
|
|
[K in keyof Tables[T]["Row"]]: K extends keyof Tables[T]["Insert"]
|
|
? ColumnType<
|
|
Tables[T]["Row"][K],
|
|
Tables[T]["Insert"][K],
|
|
K extends keyof Tables[T]["Update"] ? Tables[T]["Update"][K] : never
|
|
>
|
|
: // Spalten, die es nur beim Lesen gibt (von Triggern gesetzt).
|
|
ColumnType<Tables[T]["Row"][K], never, never>;
|
|
};
|
|
|
|
export type DB = { [T in keyof Tables]: Columns<T> };
|
|
|
|
/** Die Tabelle, die auth.users ablöst. Noch nicht in der Alt-Beschreibung. */
|
|
export type AppUsersTable = {
|
|
id: ColumnType<string, string | undefined, never>;
|
|
external_id: string;
|
|
email: string;
|
|
full_name: ColumnType<string | null, string | null | undefined, string | null>;
|
|
created_at: ColumnType<string, string | undefined, never>;
|
|
last_seen_at: ColumnType<string | null, string | null | undefined, string | null>;
|
|
};
|
|
|
|
export type Schema = DB & { app_users: AppUsersTable };
|