Talk to PostgreSQL directly, and let the pooled connection forget
Zweiter Schritt weg von Supabase. Sämtliche 49 Lesezugriffe und alle
Mutationen laufen jetzt über lib/db statt über die REST-Schicht: Kysely auf
einem pg-Pool, jede Abfrage in einer Transaktion, in der zuerst
app.user_id gesetzt wird. Die Anmeldung hängt noch an GoTrue — sie liefert
die Kennung, die in withUser() geht. Damit war der Umbau in zwei Hälften
teilbar und die Anwendung durchgehend lauffähig.
Was dabei ersatzlos verschwindet:
- fetchAllRows. Es gab die Funktion nur, weil PostgREST jede Antwort bei
1000 Zeilen still abschneidet und ein Bericht dann leise falsch war.
Am direkten Zugang ist eine Abfrage eine Abfrage.
- sanitizeIlikeTerm samt Test. Sie entschärfte Zeichen, die in der
Filtersyntax strukturelle Bedeutung hatten; jetzt wird der Suchbegriff
als Parameter gebunden und ein Komma ist ein Komma. Die Lücke ist nicht
abgesichert, sondern weg.
- lib/supabase/admin.ts. Der Dienstschlüssel, der RLS aushebelte, hatte
genau einen Aufrufer — den nächtlichen Lauf. Der benutzt jetzt dieselbe
Rolle ohne BYPASSRLS und ruft eine SECURITY-DEFINER-Funktion auf, die
selbst prüft, was sie tut. Es gibt keinen privilegierten Zugang mehr.
Nebenbei besser geworden, weil der direkte Zugang es erlaubt:
- Eine Seite ist eine Transaktion. Das Layout etwa liest Profil,
Planstellen, Standorte, Entwürfe und Notizen auf einem einheitlichen
Lesestand statt in fünf unabhängigen Anfragen.
- Der Bereichsfilter der Mitarbeiterliste ist ein EXISTS statt einer
eingebetteten Ressource mit !inner — eine Person mit mehreren
Zuordnungen über die Zeit erschien dort mehrfach.
- Seitenweise Listen sortieren zusätzlich nach id. Bei gleichem Nachnamen
oder gleichem Zeitstempel war die Reihenfolge vorher unbestimmt, und
dieselbe Zeile konnte auf zwei Seiten erscheinen oder auf keiner.
- Angehörige werden in der Datenbank gezählt statt alle Zeilen zu holen.
- Namen an Ereigniszeilen kommen aus einem Join statt aus einem
Nachschlag, der ausserhalb der Transaktion lag.
Der Statusfilter ist mitgezogen: dieselbe Regel wie deriveStatusAsOf,
Klausel für Klausel, jetzt als Kysely-Ausdruck. Der Integrationstest, der
beide über den gesamten Bestand vergleicht, läuft weiter — mit eigener
Verbindung, denn geprüft wird die Bedingung, nicht die Berechtigung.
Zwei Fehler auf dem Weg, beide vom Typprüfer gefangen: apply_due_pending_
changes() nimmt kein Argument, wurde von callFunction aber mit jsonb
aufgerufen — Postgres hätte keine passende Signatur gefunden. Und der
Sicherheitstest lädt jetzt Module mit `import "server-only"`, was ausserhalb
der Server-Übersetzung wirft.
Typecheck, Lint, Build und 180 Tests sind grün. Ungeprüft bleibt der Lauf
gegen eine echte Datenbank — dafür fehlt eine DATABASE_URL.
This commit is contained in:
@@ -16,7 +16,8 @@ import {
|
||||
totalForRows,
|
||||
} from "@/lib/reports";
|
||||
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
type SearchParams = {
|
||||
mode?: string;
|
||||
@@ -35,7 +36,6 @@ type SearchParams = {
|
||||
|
||||
export default async function ReportsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const params = await searchParams;
|
||||
const supabase = await createClient();
|
||||
const mode = parseMode(params.mode);
|
||||
|
||||
// Both modes are parsed up front so the data load can start before
|
||||
@@ -55,34 +55,44 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
// in, so all three go out together. Against a hosted database a round trip
|
||||
// costs about as much as the query itself, which made this page's three
|
||||
// sequential waves its dominant cost.
|
||||
const [{ lookups, divisions, locations }, { data: userRes }, events, employees] = await Promise.all([
|
||||
loadOrgLookups(supabase),
|
||||
supabase.auth.getUser(),
|
||||
mode === "events"
|
||||
? loadEventHistory(supabase, {
|
||||
eventType: eventType ?? undefined,
|
||||
division: params.division,
|
||||
location: params.location,
|
||||
from,
|
||||
to,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
mode === "snapshot"
|
||||
? loadSnapshotEmployees(supabase, {
|
||||
division: params.division,
|
||||
location: params.location,
|
||||
status: params.status,
|
||||
employment: params.employment,
|
||||
asOf,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const userId = await currentUserId();
|
||||
|
||||
const user = userRes.user;
|
||||
// Still a wave of its own: it needs the user id the call above resolves.
|
||||
const { data: savedReports } = user
|
||||
? await supabase.from("saved_reports").select("id, name, config").eq("created_by", user.id).order("created_at", { ascending: false })
|
||||
: { data: [] };
|
||||
// Alles in einer Transaktion — dort gilt der Sitzungskontext, und der
|
||||
// Lesestand ist über alle Abfragen hinweg derselbe. Vorher waren es drei
|
||||
// Wellen nacheinander, was gegen eine entfernte Datenbank der teuerste
|
||||
// Teil dieser Seite war.
|
||||
const { lookups, divisions, locations, events, employees, savedReports } = await withUser(userId, async (tx) => {
|
||||
const [{ lookups, divisions, locations }, events, employees, savedReports] = await Promise.all([
|
||||
loadOrgLookups(tx),
|
||||
mode === "events"
|
||||
? loadEventHistory(tx, {
|
||||
eventType: eventType ?? undefined,
|
||||
division: params.division,
|
||||
location: params.location,
|
||||
from,
|
||||
to,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
mode === "snapshot"
|
||||
? loadSnapshotEmployees(tx, {
|
||||
division: params.division,
|
||||
location: params.location,
|
||||
status: params.status,
|
||||
employment: params.employment,
|
||||
asOf,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
userId
|
||||
? tx
|
||||
.selectFrom("saved_reports")
|
||||
.select(["id", "name", "config"])
|
||||
.where("created_by", "=", userId)
|
||||
.orderBy("created_at", "desc")
|
||||
.execute()
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
return { lookups, divisions, locations, events, employees, savedReports };
|
||||
});
|
||||
|
||||
if (mode === "events") {
|
||||
const rows = aggregateEvents(events, eventGroup, eventSplit, lookups);
|
||||
@@ -100,7 +110,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
recordCount={events.length}
|
||||
divisions={divisions}
|
||||
locations={locations}
|
||||
savedReports={savedReports ?? []}
|
||||
savedReports={savedReports}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
@@ -127,7 +137,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
recordCount={employees.length}
|
||||
divisions={divisions}
|
||||
locations={locations}
|
||||
savedReports={savedReports ?? []}
|
||||
savedReports={savedReports}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user