Files
alpenwerk-hr/lib/org.ts
Maximilian Stubhan 91b2b3406b Stop waiting on the network eleven times per page
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>
2026-08-16 19:31:36 +02:00

148 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { ExpressionBuilder } from "kysely";
import type { Tx } from "./db";
import { jsonArrayFrom } from "./db/json";
import type { Schema } from "./db/schema";
import type { Database } from "./supabase/types";
/**
* Der Ausdrucksbauer einer Abfrage ohne eigene Tabelle (selectNoFrom) — das
* ist der Ort, an dem Teilabfragen zusammengehängt werden.
*/
export type OrgEb = ExpressionBuilder<Schema, never>;
// Die Organisation ist ein Baum, keine drei Tabellen mehr. Alles, was früher
// aus divisions/departments/teams zusammengesteckt wurde, ergibt sich jetzt
// aus org_units.parent_id — und damit funktioniert es auch für eine fünfte
// Ebene, ohne dass hier etwas zu ändern wäre.
export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
export type OrgUnit = {
id: string;
org_number: string;
name: string;
parent_id: string | null;
unit_type: OrgUnitType;
};
export type Location = Database["public"]["Tables"]["locations"]["Row"];
export type OrgMaps = {
units: Map<string, OrgUnit>;
/** Tiefensuche ab der Wurzel: eine Einheit steht immer hinter ihrem Elternteil. */
unitList: OrgUnit[];
/** Abstand zur Wurzel; die Wurzel selbst hat 0. */
depthOf: Map<string, number>;
childrenOf: Map<string | null, OrgUnit[]>;
locations: Map<string, Location>;
locationList: Location[];
};
// Die Referenzdaten sind winzig (60 Einheiten, 5 Standorte) — sie werden
// ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen.
/**
* Einheiten und Standorte als *Teilabfragen* — zum Einhängen in die eine
* Abfrage, die eine Seite ohnehin stellt.
*
* Fast jede Seite braucht den Baum. Als eigene Abfrage wäre das je Seitenauf-
* bau eine zusätzliche Rundreise für 65 Zeilen, die sich selten ändern; als
* Teilabfrage kostet sie nichts (lib/db/json.ts).
*/
export function orgMapsAbfragen(eb: OrgEb) {
return [
jsonArrayFrom(
eb.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number")
).as("units"),
jsonArrayFrom(eb.selectFrom("locations").selectAll().orderBy("name")).as("locations"),
] as const;
}
export async function loadOrgMaps(tx: Tx): Promise<OrgMaps> {
// Beides in einer Rundreise. Ein Promise.all wäre hier keine
// Gleichzeitigkeit gewesen, sondern eine Schlange — der Grund steht in
// lib/db/json.ts.
const { units, locations } = await tx
.selectNoFrom((eb) => [...orgMapsAbfragen(eb)])
.executeTakeFirstOrThrow();
return buildOrgMaps(units as OrgUnit[], locations as Location[]);
}
/** Der reine Teil: aus den Zeilen den Baum bauen, ohne Datenbank. */
export function buildOrgMaps(units: OrgUnit[], locations: Location[]): OrgMaps {
const childrenOf = new Map<string | null, OrgUnit[]>();
for (const u of units) {
const list = childrenOf.get(u.parent_id) ?? [];
list.push(u);
childrenOf.set(u.parent_id, list);
}
for (const list of childrenOf.values()) list.sort((a, b) => a.name.localeCompare(b.name, "de"));
const unitList: OrgUnit[] = [];
const depthOf = new Map<string, number>();
const walk = (parentId: string | null, depth: number) => {
for (const u of childrenOf.get(parentId) ?? []) {
unitList.push(u);
depthOf.set(u.id, depth);
walk(u.id, depth + 1);
}
};
walk(null, 0);
return {
units: new Map(units.map((u) => [u.id, u])),
unitList,
depthOf,
childrenOf,
locations: new Map(locations.map((l) => [l.id, l])),
locationList: locations,
};
}
/** Wurzel zuerst, die Einheit selbst zuletzt. */
export function ancestorsOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit[] {
const chain: OrgUnit[] = [];
const seen = new Set<string>();
let current = unitId ? maps.units.get(unitId) : undefined;
while (current && !seen.has(current.id)) {
seen.add(current.id);
chain.unshift(current);
current = current.parent_id ? maps.units.get(current.parent_id) : undefined;
}
return chain;
}
/** Die Einheit und alles darunter — die Menge, die ein Filter „Bereich X" meint. */
export function subtreeOf(maps: OrgMaps, unitId: string): string[] {
const out: string[] = [];
const queue = [unitId];
const seen = new Set<string>();
while (queue.length > 0) {
const id = queue.shift()!;
if (seen.has(id)) continue;
seen.add(id);
out.push(id);
for (const child of maps.childrenOf.get(id) ?? []) queue.push(child.id);
}
return out;
}
/**
* „Produktion › Fertigung › Montage". Die Gesellschaft bleibt weg: sie steht
* über allem und trägt in einer Zeile nichts bei.
*/
export function breadcrumbLabel(maps: OrgMaps, unitId: string | null | undefined): string {
const chain = ancestorsOf(maps, unitId).filter((u) => u.unit_type !== "Gesellschaft");
return chain.map((u) => u.name).join(" › ") || "–";
}
/** Die oberste Einheit unterhalb der Gesellschaft — das, was früher „Bereich" hiess. */
export function divisionOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit | undefined {
return ancestorsOf(maps, unitId).find((u) => u.unit_type !== "Gesellschaft");
}
/** Die Einheit selbst, wenn sie nicht die Gesellschaft ist. */
export function unitOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit | undefined {
return unitId ? maps.units.get(unitId) : undefined;
}