feat(laeufe): Live-Aktualisierung per Ereignisstrom statt Polling (#53)
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Die Abarbeitungsansicht fragte alle zehn Sekunden nach dem Zustand. Jetzt
melden vier Datenbank-Trigger `NOTIFY lauf_geaendert, '<laufId>'`, eine
LISTEN-Verbindung je Prozess hoert zu, und der Route Handler schiebt
weiter. Gemessen: der Haken des Kollegen ist nach 485 ms beim Zuschauer
(am Handy 487 ms), vorher bis zu zehn Sekunden. Zwanzig offene Laschen
kosten nichts mehr statt zwanzig Abfragen je zehn Sekunden.

Das Ereignis kommt aus der Datenbank und nicht aus dem Dienst, aus
demselben Grund, aus dem auch die Regeln dort stehen: ein Trigger sieht
auch den Seed, eine Migration und die Reparatur per psql. Und NOTIFY
erreicht jede Instanz der Anwendung, ohne einen zweiten Dienst.

Welche Tabellen melden, haengt an dem, was `laufZustand` liefert — nicht
daran, was das Issue vorgeschlagen hat. Mit Triggern nur auf
Schrittergebnis und Lauf waere der Strom ein **Rueckschritt** gewesen:
das Polling verglich den ganzen Stand und sah deshalb auch Kommentare,
ein Strom sieht nur, was gemeldet wird. Der Kommentar des Kollegen waere
nie angekommen, und die Seite haette ausgesehen wie immer. Es melden
jetzt vier Tabellen, und `laufZustand` traegt `anzahlAnhaenge` — ein
Ereignis ohne Entsprechung im Zustand loest nichts aus.

Das Polling bleibt als Rueckfall an **derselben** Adresse, unterschieden
nur am Accept-Kopf: eine Rechtepruefung, ein Lesemodell, eine Nutzlast.
Ein zweiter Endpunkt waere eine zweite Stelle, an der jemand
rechtErforderlich vergessen kann. Bei erreichter Obergrenze antwortet
der Handler gewoehnlich mit JSON; EventSource meldet darauf einen Fehler
und versucht es nicht wieder, also faellt der Client von sich aus
zurueck — ohne Sonderfall.

Nebenbei zwei Fehler in docs/betrieb/reverse-proxy.md behoben, beide aus
#52: `client_max_body_size 8m` bei `ANHANG_MAX_MB=10` haette ein
10-MB-Foto mit 413 abgewiesen, bevor die eigene Meldung zum Zuge kommt.

Closes #53

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-05 22:50:32 +02:00
co-authored by Claude Opus 5
parent 3ccf3aa2d9
commit 1be73b266c
20 changed files with 1640 additions and 84 deletions
+34 -2
View File
@@ -5,12 +5,19 @@ import { laufZumAbarbeiten, laufZustand } from "@/server/queries/laeufe";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Der Zustand fuer die Aktualisierung alle zehn Sekunden (#013).
* Der Zustand, von dem die Aktualisierung lebt (#013, Strom seit #033).
*
* Zwei Zusagen haengen daran: zwei Browser am selben Lauf sehen die Aenderung
* des anderen — und die Antwort enthaelt **keine Befehle**, weil ein
* `{{generiertes_passwort}}` bei jeder Aufloesung neu entsteht und dem Techniker
* sonst unter den Haenden wegtauschte.
*
* Seit #033 kommt der Stand ueber einen Ereignisstrom statt alle zehn Sekunden.
* Am Lesemodell aendert das nichts — **aber an seiner Vollstaendigkeit hängt
* jetzt mehr**: das Polling verglich den ganzen Stand und sah damit auch, was
* kein Trigger meldet. Was hier fehlt, loest nun gar keine Aktualisierung mehr
* aus. Deshalb zaehlt der Test unten die Schluessel namentlich auf, und
* `tests/lauf-ereignisse.test.ts` prueft, dass zu jedem eine Meldung gehoert.
*/
async function aufbau() {
@@ -137,6 +144,7 @@ describe.runIf(datenbankVerfuegbar)("Zustand eines Laufs", () => {
expect(alsText).not.toContain("Password");
expect(alsText).not.toContain("{{");
expect(Object.keys(zustand ?? {}).sort()).toEqual([
"anzahlAnhaenge",
"anzahlKommentare",
"anzahlPflichtOffen",
"geaendertAm",
@@ -163,7 +171,31 @@ describe.runIf(datenbankVerfuegbar)("Zustand eines Laufs", () => {
expect(mitKommentar).not.toBe(vorher);
await testPrisma.lauf.update({ where: { id: lauf.id }, data: { titel: "PC-001 (neuer Titel)" } });
expect(JSON.stringify(await laufZustand(lauf.id, benutzer))).not.toBe(mitKommentar);
const mitTitel = JSON.stringify(await laufZustand(lauf.id, benutzer));
expect(mitTitel).not.toBe(mitKommentar);
/*
* Und ein Anhang (#032/#033). Derselbe Grund wie beim Kommentar: ein Foto,
* das der Kollege gerade an einen Schritt gehaengt hat, blieb sonst
* unsichtbar — und seit der Stand nicht mehr alle zehn Sekunden vollstaendig
* verglichen wird, bliebe es das fuer immer.
*/
const ergebnis = await testPrisma.schrittergebnis.findFirstOrThrow({
where: { laufId: lauf.id },
select: { id: true },
});
await testPrisma.anhang.create({
data: {
schrittergebnisId: ergebnis.id,
laufId: lauf.id,
kundeId: lauf.kundeId,
dateiname: "typenschild.png",
medientyp: "image/png",
groesse: 42,
pruefsumme: "c".repeat(64),
},
});
expect(JSON.stringify(await laufZustand(lauf.id, benutzer))).not.toBe(mitTitel);
});
it("liefert den Status mit", async () => {