The app runs against a real database for the first time since the port, and
two things were broken. Both were invisible to typecheck, lint, 192 tests
and the build.
Sign-in looped. Auth.js created the session and app_upsert_user() adopted
the existing profiles id correctly — the row was in app_users, right id and
all — but the proxy builds its own Auth.js instance from lib/auth/config.ts
alone, and the session callback that copies token.uid onto session.user.id
lived in auth.ts. So the proxy saw a session without an id, treated every
signed-in user as signed out, and sent them back to /login. Click, flash,
login page: from the outside it looked like the button did nothing.
The callback moves to the config both instances share. auth.ts now spreads
the base callbacks instead of replacing them, which is the mistake that
would reintroduce this.
The proxy test did not catch it because its fixture hands the handler a
session that already has user.id — it tested the routing, not the shape
Auth.js actually produces.
Then the dashboard crashed on a.date.localeCompare. PostgREST returned JSON:
a `date` arrived as "2026-08-03", a `numeric` as a number, and that is what
lib/supabase/types.ts declares and what every sort, every date comparison
and every status derivation assumes. The pg driver does the opposite — Date
object and string respectively. The declarations stayed true to what the
code believes; only the runtime value changed, which is why nothing flagged
it.
The driver is configured back to the declared shapes in lib/db/pool.ts,
rather than rewriting 49 call sites. That also removes a timezone hazard:
`date` is a calendar day, and as a Date object it acquires midnight in the
server's zone — a birth date would shift by a day in Austria, always. The
same class of bug as in the seed.
int8 stays a string on purpose: it only comes from count() and is read
through Number() everywhere; parsed as a number it would quietly lose
precision past 2^53.
A missing sign-in error now reaches the server log. Auth.js was failing
silently — a 302 back to /login and nothing to read. That was its own
defect, and it is the reason the first diagnosis took as long as it did.
Verified against the live database: all six pages render, 797 active of
852 records, 744.4 FTE, and a detail page shows birth date 15.08.1968
against SV number 7960 150868 — the digits agree, so no day has shifted.
Both new tests were checked by mutation: remove the fix and they fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.