The app got slower as pages grew, and the reason was not the queries. It was their number. A transaction is pinned to one connection, and a connection runs queries one after another. Every Promise.all in a withUser block looked like concurrency and was a queue. Measured against the real database: the round trip is ~36 ms, ten trivial `select 1` over one connection take 343 ms, over ten connections 39 ms. Nothing here is slow — the whole dashboard payload is under 200 kB, and every table is around a thousand rows. More connections is the wrong answer: the RLS session context is per transaction, so parallel reads mean parallel transactions, and those multiply the connections the database will grant. Fewer round trips instead. Postgres will return each sub-select as its own JSON column of one result. Per page view, counting the transaction frame: shell (paid by every page) 10 → 4 overview 14 → 5 employee file 14 → 7 employee list 8 → 6 The overview plus its shell went from 24 round trips to 9 — about 860 ms of pure waiting down to about 320 ms. The one trap is documented where it bites: inside json_agg, Postgres formats values itself and the driver's parsers (lib/db/pool.ts) never see them. Dates, numerics and uuids come out identical; timestamptz does not — "+00:00" where the driver gives "…Z". Timestamps are compared as strings in lib/history.ts to decide what happened later, and those two forms sort against each other wrongly. Every timestamptz in a bundled query therefore goes through zeitstempel(), which was checked character-for-character against the driver. Four loaders moved out of their pages into lib/ so the number of round trips can be measured without building a React tree, and so the new path could be held against the old one field by field: same rows, same order, same strings, for the overview and for four employee files chosen to differ (with history, a chief, a planned entry, one with dependents). withUser now counts the queries in each transaction and says so in development past a threshold. Without that, this grows back: each new tile brings its own query, and nobody notices until everybody does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
77 lines
3.1 KiB
TypeScript
77 lines
3.1 KiB
TypeScript
import type { Tx } from "./db";
|
|
import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json";
|
|
import { todayIso } from "./format";
|
|
import { baueOffeneNotizen, offeneNotizenAbfrage, type NotizZeile, type OpenNote } from "./notes";
|
|
import { buildOrgMaps, orgMapsAbfragen, type Location } from "./org";
|
|
import {
|
|
offeneStellenAbfrage,
|
|
resolveOpenPositions,
|
|
type OffeneStelle,
|
|
type OpenPositionResolved,
|
|
} from "./positions";
|
|
|
|
// Was die Hülle jeder Seite braucht — in zwei Rundreisen statt in sechs.
|
|
//
|
|
// Vorher stand das im Layout selbst, als Promise.all, das wie Gleichzeitigkeit
|
|
// aussah und keine war: eine Transaktion hängt an einer Verbindung, und über
|
|
// eine Verbindung laufen Abfragen nacheinander. Bei rund 36 ms Umlaufzeit
|
|
// kostete diese Hülle — die **jede** Seite mitlädt — eine halbe Sekunde
|
|
// Warten für ein paar Kilobyte. Der Weg dahin steht in lib/db/json.ts.
|
|
//
|
|
// Hier und nicht im Layout, damit sich die Zahl der Rundreisen messen lässt,
|
|
// ohne eine React-Komponente aufzubauen.
|
|
|
|
export type ShellData = {
|
|
profile: { full_name: string | null; email: string | null; role: string | null; is_active: boolean | null };
|
|
openPositions: OpenPositionResolved[];
|
|
locations: Location[];
|
|
drafts: { id: string; step: number; payload: Record<string, unknown>; updated_at: string }[];
|
|
openNotes: OpenNote[];
|
|
};
|
|
|
|
/**
|
|
* `null` heisst: angemeldet, aber nicht als HR freigeschaltet.
|
|
*
|
|
* Die Zugangsprüfung fragt gleichzeitig mit dem Rest statt davor. Das liest
|
|
* ein paar Zeilen mehr, als eine gesperrte Person sehen dürfte, wirft sie aber
|
|
* weg, ohne sie je auszuliefern — und die eigentliche Grenze ist ohnehin RLS,
|
|
* nicht die Reihenfolge hier.
|
|
*/
|
|
export async function loadShellData(tx: Tx, userId: string): Promise<ShellData | null> {
|
|
const asOf = todayIso();
|
|
|
|
const gelesen = await tx
|
|
.selectNoFrom((eb) => [
|
|
jsonObjectFrom(
|
|
eb.selectFrom("profiles").select(["full_name", "email", "role", "is_active"]).where("id", "=", userId)
|
|
).as("profile"),
|
|
...orgMapsAbfragen(eb),
|
|
jsonArrayFrom(offeneStellenAbfrage(eb, asOf)).as("open"),
|
|
jsonArrayFrom(offeneNotizenAbfrage(eb)).as("notes"),
|
|
jsonArrayFrom(
|
|
eb
|
|
.selectFrom("hire_drafts")
|
|
.select(["id", "step", "payload"])
|
|
.select((x) => zeitstempel(x.ref("updated_at")).as("updated_at"))
|
|
.where("created_by", "=", userId)
|
|
.orderBy("updated_at", "desc")
|
|
).as("drafts"),
|
|
])
|
|
.executeTakeFirstOrThrow();
|
|
|
|
const profile = gelesen.profile;
|
|
if (profile?.role !== "hr" || profile?.is_active !== true) return null;
|
|
|
|
const orgMaps = buildOrgMaps(gelesen.units as never, gelesen.locations as never);
|
|
|
|
return {
|
|
profile,
|
|
// Die zweite Rundreise: was sie fragt, hängt davon ab, welche Stellen
|
|
// offen sind — das lässt sich nicht in die erste ziehen.
|
|
openPositions: await resolveOpenPositions(tx, orgMaps, gelesen.open as OffeneStelle[], asOf),
|
|
locations: gelesen.locations as Location[],
|
|
drafts: gelesen.drafts as ShellData["drafts"],
|
|
openNotes: baueOffeneNotizen(gelesen.notes as NotizZeile[]),
|
|
};
|
|
}
|