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:
@@ -1,6 +1,4 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { fetchAllRows } from "./supabase/query";
|
||||
import type { Database } from "./supabase/types";
|
||||
import { sql, type Tx } from "./db";
|
||||
|
||||
// Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es
|
||||
// ergibt sich aus der Planstelle, die sie zum Stichtag innehat:
|
||||
@@ -24,30 +22,25 @@ export type Placement = {
|
||||
current: boolean;
|
||||
};
|
||||
|
||||
const SELECT =
|
||||
"employee_id, valid_from, valid_to, om_positions!inner(id, position_number, org_unit_id, is_chief, jobs!inner(title))";
|
||||
|
||||
type Row = {
|
||||
employee_id: string;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
om_positions: {
|
||||
id: string;
|
||||
position_number: string;
|
||||
org_unit_id: string;
|
||||
is_chief: boolean;
|
||||
jobs: { title: string };
|
||||
};
|
||||
position_id: string;
|
||||
position_number: string;
|
||||
org_unit_id: string;
|
||||
is_chief: boolean;
|
||||
job_title: string;
|
||||
};
|
||||
|
||||
function toPlacement(row: Row, asOf: string): Placement {
|
||||
return {
|
||||
employeeId: row.employee_id,
|
||||
positionId: row.om_positions.id,
|
||||
positionNumber: row.om_positions.position_number,
|
||||
orgUnitId: row.om_positions.org_unit_id,
|
||||
isChief: row.om_positions.is_chief,
|
||||
jobTitle: row.om_positions.jobs.title,
|
||||
positionId: row.position_id,
|
||||
positionNumber: row.position_number,
|
||||
orgUnitId: row.org_unit_id,
|
||||
isChief: row.is_chief,
|
||||
jobTitle: row.job_title,
|
||||
validFrom: row.valid_from,
|
||||
validTo: row.valid_to,
|
||||
current: row.valid_from <= asOf && (row.valid_to === null || row.valid_to > asOf),
|
||||
@@ -76,17 +69,33 @@ export function pickPlacements(rows: Row[], asOf: string): Map<string, Placement
|
||||
}
|
||||
|
||||
export async function loadPlacements(
|
||||
supabase: SupabaseClient<Database>,
|
||||
tx: Tx,
|
||||
{ asOf, employeeIds }: { asOf: string; employeeIds?: string[] }
|
||||
): Promise<Map<string, Placement>> {
|
||||
if (employeeIds?.length === 0) return new Map();
|
||||
|
||||
const rows = await fetchAllRows(() => {
|
||||
const q = supabase.from("position_assignments").select(SELECT).order("employee_id");
|
||||
return employeeIds ? q.in("employee_id", employeeIds) : q;
|
||||
});
|
||||
// Ein Join statt einer eingebetteten Ressource. Und ohne die
|
||||
// 1000-Zeilen-Grenze von PostgREST fällt das seitenweise Nachladen weg,
|
||||
// das es dafür brauchte.
|
||||
let q = tx
|
||||
.selectFrom("position_assignments as pa")
|
||||
.innerJoin("om_positions as p", "p.id", "pa.position_id")
|
||||
.innerJoin("jobs as j", "j.id", "p.job_id")
|
||||
.select([
|
||||
"pa.employee_id",
|
||||
"pa.valid_from",
|
||||
"pa.valid_to",
|
||||
"p.id as position_id",
|
||||
"p.position_number",
|
||||
"p.org_unit_id",
|
||||
"p.is_chief",
|
||||
"j.title as job_title",
|
||||
])
|
||||
.orderBy("pa.employee_id");
|
||||
|
||||
return pickPlacements(rows as unknown as Row[], asOf);
|
||||
if (employeeIds) q = q.where("pa.employee_id", "in", employeeIds);
|
||||
|
||||
return pickPlacements((await q.execute()) as Row[], asOf);
|
||||
}
|
||||
|
||||
// ── Abgeleitete Berichtslinie ──────────────────────────────────────
|
||||
@@ -105,11 +114,30 @@ export type ReportingLine = {
|
||||
acting_manager_id: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* `filter` schränkt die Funktion selbst ein, nicht das Ergebnis im Speicher —
|
||||
* bei der Detailseite wandern damit neun Zeilen über die Leitung statt
|
||||
* achthundert.
|
||||
*/
|
||||
export async function loadReportingLines(
|
||||
supabase: SupabaseClient<Database>,
|
||||
asOf: string
|
||||
): Promise<Map<string, ReportingLine>> {
|
||||
const { data, error } = await supabase.rpc("om_reporting_lines", { p_as_of: asOf });
|
||||
if (error) throw new Error(`Berichtslinie konnte nicht geladen werden: ${error.message}`);
|
||||
return new Map(((data ?? []) as ReportingLine[]).map((l) => [l.employee_id, l]));
|
||||
tx: Tx,
|
||||
asOf: string,
|
||||
filter?: { employeeId?: string; actingManagerId?: string }
|
||||
): Promise<ReportingLine[]> {
|
||||
const conditions = [sql`true`];
|
||||
if (filter?.employeeId) conditions.push(sql`employee_id = ${filter.employeeId}::uuid`);
|
||||
if (filter?.actingManagerId) conditions.push(sql`acting_manager_id = ${filter.actingManagerId}::uuid`);
|
||||
|
||||
const result = await sql<ReportingLine>`
|
||||
select * from om_reporting_lines(${asOf}::date)
|
||||
where ${sql.join(conditions, sql` and `)}
|
||||
`.execute(tx);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/** Wie loadReportingLines, aber als Karte über die Personen-Kennung. */
|
||||
export async function loadReportingLineMap(tx: Tx, asOf: string): Promise<Map<string, ReportingLine>> {
|
||||
const lines = await loadReportingLines(tx, asOf);
|
||||
return new Map(lines.map((l) => [l.employee_id, l]));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user