From 1be73b266c83cac998da0c8410943dad00ff4f91 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 5 Oct 2026 09:36:10 +0200 Subject: [PATCH] feat(laeufe): Live-Aktualisierung per Ereignisstrom statt Polling (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Abarbeitungsansicht fragte alle zehn Sekunden nach dem Zustand. Jetzt melden vier Datenbank-Trigger `NOTIFY lauf_geaendert, ''`, 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) --- .env.example | 17 + CHANGELOG.md | 20 + CLAUDE.md | 3 +- docs/anleitung/02-laeufe-abarbeiten.md | 14 +- docs/anleitung/08-fehlerbehebung.md | 22 + docs/architektur.md | 52 +- docs/betrieb/reverse-proxy.md | 39 +- docs/devlog.md | 67 ++- docs/issues/033-sse-live.md | 72 ++- docs/issues/README.md | 4 +- .../migration.sql | 84 +++ src/app/api/laeufe/[id]/zustand/route.ts | 155 +++++- src/components/laeufe/lauf-aktualisierung.tsx | 178 +++++- src/lib/sse.ts | 79 +++ src/server/lauf-ereignisse.ts | 195 +++++++ src/server/queries/laeufe.ts | 13 +- tests/datenbankregeln.test.ts | 24 +- tests/e2e/live.spec.ts | 141 +++++ tests/lauf-ereignisse.test.ts | 509 ++++++++++++++++++ tests/zustand-route.test.ts | 36 +- 20 files changed, 1640 insertions(+), 84 deletions(-) create mode 100644 prisma/migrations/20261005090000_lauf_ereignisse/migration.sql create mode 100644 src/lib/sse.ts create mode 100644 src/server/lauf-ereignisse.ts create mode 100644 tests/e2e/live.spec.ts create mode 100644 tests/lauf-ereignisse.test.ts diff --git a/.env.example b/.env.example index 3d36044..fd81b84 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,23 @@ ANHAENGE_PFAD=./daten/anhaenge # Hoechstgroesse je Datei in MB und Hoechstzahl Anhaenge je Lauf. ANHANG_MAX_MB=10 ANHAENGE_JE_LAUF=20 +# +# **Wer das anhebt, hebt `client_max_body_size` im Reverse Proxy mit an.** Sonst +# weist nginx die Datei mit 413 ab, bevor die Anwendung ihre eigene, freundliche +# Meldung zeigen kann — siehe docs/betrieb/reverse-proxy.md. + +# ---- Live-Aktualisierung (#033) -------------------------------------------- +# Die Abarbeitungsansicht haelt einen Ereignisstrom offen, statt alle zehn +# Sekunden zu fragen. Beide Werte brauchen im Normalfall niemand anzufassen. +# +# Abstand der Herzschlaege in Millisekunden. Muss **unter** der Leerlaufzeit des +# Reverse Proxy liegen (nginx und Caddy: 60 s), sonst schliesst der die stille +# Verbindung. +SSE_HERZSCHLAG_MS=25000 +# Hoechstzahl gleichzeitig offener Stroeme je Instanz. Ist sie erreicht, fallen +# weitere Laschen von sich aus auf das Polling zurueck — die Seite bleibt +# bedienbar, nur nicht mehr sofort aktuell. +SSE_MAX_STROEME=200 # Entwicklungsdatenbank aus docker-compose.dev.yml (Port 5434, umstellbar ueber DEV_DB_PORT). POSTGRES_USER=laufzettel diff --git a/CHANGELOG.md b/CHANGELOG.md index 198d70e..5477ff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Geändert +- **Die Abarbeitungsansicht aktualisiert sich jetzt sofort statt alle zehn Sekunden.** Hakt ein + Kollege einen Schritt ab, schreibt einen Kommentar, hängt ein Foto an oder ändert den Titel, + erscheint das beim anderen in weniger als einer halben Sekunde — gemessen 485 ms, vorher bis zu + zehn Sekunden. Dahinter stehen vier Datenbank-Trigger mit `NOTIFY` und ein Ereignisstrom; das + funktioniert auch mit mehreren Instanzen der Anwendung, ohne einen zweiten Dienst. Zwanzig offene + Laschen kosten jetzt nichts mehr, statt zwanzig Abfragen je zehn Sekunden. + **Das Polling bleibt als Rückfall** an derselben Adresse, mit derselben Rechteprüfung: wer kein + `EventSource` hat oder hinter einem Proxy sitzt, der Ströme puffert, bekommt den alten Weg. Neu in + der Umgebung: `SSE_HERZSCHLAG_MS` (25000) und `SSE_MAX_STROEME` (200) (#033) +- Der Zustand eines Laufs trägt jetzt auch die Zahl der **Anhänge**. Ohne sie wäre ein angehängtes + Foto für die zweite Sitzung unsichtbar geblieben — die Anhänge kamen in #032 dazu, der Zustand + kannte sie nicht (#033) - **`docs/betrieb/sicherung.md` ist neu geschrieben.** Dort stand „Kein Upload-Verzeichnis, keine erzeugten Dateien“ — mit den Anhängen ist das falsch, und eine Sicherung nach der alten Anleitung hätte ab #032 still Daten verloren: in der Datenbank stehen nur die Metadaten. Jetzt sind es drei @@ -31,6 +43,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). - Im README fehlte `format:pruefen` in den Qualitätsschranken, obwohl `CLAUDE.md` es seit #010 verlangt (#040) +### Behoben + +- **nginx hätte ein 10-MB-Foto abgewiesen.** `docs/betrieb/reverse-proxy.md` empfahl + `client_max_body_size 8m`, während `ANHANG_MAX_MB` 10 MB erlaubt: ein Handyfoto am oberen Rand + wäre mit „413 Request Entity Too Large“ an der Wand gelandet, und die freundliche Meldung der + Anwendung wäre nie zum Zuge gekommen. Jetzt 12m, mit dem Hinweis, dass beide Werte zusammengehören + (#033) + ### Hinzugefügt - **Fotos und Dateien an einem Schritt.** „Seriennummer fotografiert“, „Typenschild“, „Zustand bei diff --git a/CLAUDE.md b/CLAUDE.md index c2d9908..ae2a7fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,8 @@ src/server/services/* Regeln und Schreiboperationen. Werfen eigene …Fehler-K src/server/queries/* Lesemodelle. Wenden Rollen- und Mandantensicht selbst an. src/server/actions/* Dünne "use server"-Einstiege. Prüfen genau ein Recht, rufen den Service. src/lib/* Framework-freie Helfer. Kein Next, kein React, gut testbar. -src/app/api/* Route Handler. Nur Polling, QR-SVG, CSV, Auth, Healthcheck. Minimal. +src/app/api/* Route Handler. Nur Zustandsstrom, QR-SVG, CSV, Anhaenge, Auth, + Healthcheck. Minimal. src/components/* Darstellung. Keine Geschäftslogik. ``` diff --git a/docs/anleitung/02-laeufe-abarbeiten.md b/docs/anleitung/02-laeufe-abarbeiten.md index 7680d00..95a5a70 100644 --- a/docs/anleitung/02-laeufe-abarbeiten.md +++ b/docs/anleitung/02-laeufe-abarbeiten.md @@ -181,8 +181,18 @@ werden mit Name und Zeitpunkt festgehalten und lassen sich nicht löschen. ### Zwei Techniker am selben Lauf -Die Seite fragt alle zehn Sekunden nach Änderungen und lädt sich neu, wenn sich wirklich etwas -geändert hat. Bei verstecktem Tab pausiert die Abfrage. +Hakt der Kollege einen Schritt ab, erscheint das bei dir **sofort** — ohne dass du etwas tun musst. +Dasselbe gilt für Kommentare, angehängte Fotos und einen geänderten Titel. Die Seite lädt sich nur +dann neu, wenn sich wirklich etwas geändert hat; an einem Feld, in das du gerade tippst, geht dabei +nichts verloren. + +Ist die Lasche im Hintergrund, pausiert das. Holst du sie zurück, steht gleich der aktuelle Stand +da. + +Bleibt die Seite doch einmal stehen, obwohl jemand anders arbeitet, aktualisiert sie sich spätestens +nach zehn Sekunden von selbst — das ist der eingebaute Rückfall. Dauert es dauerhaft so lange, liegt +es meist am Reverse Proxy; der Abschnitt „Die Seite aktualisiert sich nur langsam“ in +[Fehlerbehebung](08-fehlerbehebung.md) sagt, was zu tun ist. Steht ein erzeugtes Passwort auf dem Bildschirm, lädt sie **nicht** von selbst neu — stattdessen erscheint oben ein Streifen: „Jemand anderes hat an diesem Lauf etwas geändert. Beim Neuladen diff --git a/docs/anleitung/08-fehlerbehebung.md b/docs/anleitung/08-fehlerbehebung.md index d9d3f56..80a15cf 100644 --- a/docs/anleitung/08-fehlerbehebung.md +++ b/docs/anleitung/08-fehlerbehebung.md @@ -77,6 +77,28 @@ Zurückholen wird protokolliert. **Vorbeugen:** archiviere erst, wenn wirklich alles drinsteht. Es gibt keinen Grund zur Eile — ein abgeschlossener Lauf steht auch schon im Archiv und ist durchsuchbar. +## Die Seite aktualisiert sich nur langsam + +**Was du siehst:** Der Kollege hakt einen Schritt ab, und bei dir erscheint es erst nach rund zehn +Sekunden statt sofort. Alles funktioniert, es ist nur träge. + +**Ursache:** Die Seite hält normalerweise eine offene Verbindung zum Server, über die Änderungen +sofort ankommen. Kommt die nicht zustande, schaltet sie von selbst auf Nachfragen im +Zehn-Sekunden-Takt um — die Arbeit geht also weiter, nur nicht in Echtzeit. Fast immer liegt das am +Reverse Proxy, der die Verbindung sammelt statt sie durchzulassen. + +**Was zu tun ist** (für Administratoren): In `docs/betrieb/reverse-proxy.md` stehen die beiden +nötigen Zeilen — `proxy_buffering off` für nginx, `flush_interval -1` für Caddy — und ein +Mindestwert für `proxy_read_timeout`. Fällt der unter 30 Sekunden, reißt die Verbindung zwischen +zwei Herzschlägen immer wieder ab. + +**Zweitursache:** Sehr viele gleichzeitig offene Laschen. Ab einer Obergrenze (`SSE_MAX_STROEME`, +voreingestellt 200 je Instanz) bekommen weitere Laschen bewusst den langsamen Weg, damit der Server +erreichbar bleibt. Dann hilft, nicht mehr gebrauchte Laschen zu schließen. + +**Was es nicht ist:** ein Datenverlust. Gespeichert wird beim Klick, nicht beim Aktualisieren. Was du +siehst, kann veraltet sein; was in der Datenbank steht, ist es nie. + ## „Die Datei zu … fehlt im Dateisystem“ **Ursache:** In der Datenbank steht die Zeile eines Anhangs, die Datei selbst ist nicht da. Fast diff --git a/docs/architektur.md b/docs/architektur.md index 7193c9e..314069b 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -185,19 +185,51 @@ Das **generierte Passwort** entsteht erst beim Ersetzen und wird nirgends zurüc keinen Weg, es außerhalb des fertigen Texts zu bekommen. Gäbe es ein Feld dafür, wäre die Versuchung groß, es „nur fürs Protokoll“ mitzuschreiben. -## 5d. Warum die Aktualisierung keine Befehle mitliefert +## 5d. Wie die Aktualisierung funktioniert – und warum sie keine Befehle mitliefert -Die Abarbeitungsansicht fragt alle zehn Sekunden `GET /api/laeufe/[id]/zustand` — zwei Techniker -teilen sich ein Onboarding und sollen die Arbeit des anderen sehen, ohne die Seite neu zu laden. -Diese Antwort trägt **nur** Status und Schrittzustände, ausdrücklich keine fertigen Befehle. +Die Abarbeitungsansicht hält `GET /api/laeufe/[id]/zustand` als **Ereignisstrom** offen — zwei +Techniker teilen sich ein Onboarding und sollen die Arbeit des anderen sehen, ohne die Seite neu zu +laden. Diese Nutzlast trägt **nur** Status, Schrittzustände und die Zahl der Kommentare und +Anhänge, ausdrücklich keine fertigen Befehle. -Der Grund ist `{{generiertes_passwort}}`: es entsteht bei jedem Auflösen neu. Lieferte das Polling -Befehle mit, stünde alle zehn Sekunden ein anderes Passwort auf dem Bildschirm als das, das der -Techniker gerade in die Konsole geklebt hat. Die Befehle entstehen deshalb genau einmal je -Seitenaufbau, und der Client lädt die Seite erst neu, wenn sich am Zustand wirklich etwas geändert -hat. Bei verstecktem Tab pausiert die Abfrage. +Der Grund ist `{{generiertes_passwort}}`: es entsteht bei jedem Auflösen neu. Lieferte die +Aktualisierung Befehle mit, stünde nach jeder Änderung ein anderes Passwort auf dem Bildschirm als +das, das der Techniker gerade in die Konsole geklebt hat. Die Befehle entstehen deshalb genau einmal +je Seitenaufbau, und der Client lädt die Seite erst neu, wenn sich am Zustand wirklich etwas +geändert hat. Bei verstecktem Tab wird der Strom geschlossen. -Der Route Handler prüft sein Recht selbst und gibt einen fremden Lauf als „nicht gefunden“ zurück. +Der Route Handler prüft sein Recht selbst und gibt einen fremden Lauf als „nicht gefunden“ zurück — +auch und gerade für den Strom, **bevor** das erste Ereignis fließt. + +### Warum die Datenbank das Ereignis schickt, nicht der Dienst + +Bis #033 fragte die Seite alle zehn Sekunden. Das war richtig, aber zehn Sekunden sind lang, wenn +zwei Leute dieselbe Liste abhaken, und zwanzig offene Laschen bedeuteten zwanzig Abfragen je zehn +Sekunden. Seit #033 melden vier Trigger `NOTIFY lauf_geaendert, ''`, eine LISTEN-Verbindung +je Prozess hört zu, und der Route Handler schiebt weiter. + +**In der Datenbank und nicht im Service**, aus demselben Grund, aus dem auch die Regeln dort stehen: +ein Ereignis, das der Service verschickt, entsteht nur auf dem Weg durch den Service. Ein Trigger +sieht auch den Seed, eine Migration und die Reparatur per `psql`. Und `NOTIFY` geht an **jede** +Verbindung mit `LISTEN` — also an jede Instanz der Anwendung, ohne einen zweiten Dienst. + +**Welche Tabellen melden, hängt an dem, was das Lesemodell liefert.** Das Polling verglich den +ganzen Stand und sah damit auch, was kein Trigger meldet; ein Strom sieht nur, was gemeldet wird. +Fehlte `Kommentar` oder `Anhang`, wäre die Live-Aktualisierung gegenüber dem Polling ein +**Rückschritt** — der Kommentar des Kollegen käme nie an. `tests/datenbankregeln.test.ts` zählt die +vier Trigger namentlich auf, `tests/zustand-route.test.ts` die Schlüssel des Zustands. + +**Das Polling bleibt, als Rückfall an derselben Adresse.** Wer kein `EventSource` hat oder hinter +einem Proxy sitzt, der Ströme puffert, bekommt den alten Weg — dieselbe Rechteprüfung, dasselbe +Lesemodell, dieselbe Nutzlast. Ein zweiter Endpunkt wäre eine zweite Stelle, an der jemand das Recht +vergessen kann. Unterschieden wird allein am `Accept`-Kopf, den `EventSource` von sich aus schickt. +Dieser Rückfall wird gefahren, nicht behauptet: `tests/e2e/live.spec.ts` nimmt dem Browser +`EventSource` weg. + +Eine Obergrenze (`SSE_MAX_STROEME`, Vorgabe 200) begrenzt die offenen Ströme je Instanz. Ist sie +erreicht, antwortet der Handler gewöhnlich mit JSON; `EventSource` meldet darauf einen Fehler und +versucht es nicht wieder, und der Client fällt von sich aus auf Polling zurück. Das braucht keinen +Sonderfall und keinen eigenen Fehlercode. Die Hülle unter `src/app/(app)/layout.tsx` deckt `src/app/api/` nicht ab. ## 5e. Der Aufkleber ist die Schnittstelle diff --git a/docs/betrieb/reverse-proxy.md b/docs/betrieb/reverse-proxy.md index 12d2b7b..d923490 100644 --- a/docs/betrieb/reverse-proxy.md +++ b/docs/betrieb/reverse-proxy.md @@ -58,6 +58,14 @@ laufzettel.firma.de { # gerade beim Neustart. Der Pfad braucht keine Anmeldung. health_uri /api/gesundheit health_interval 30s + + # Die Live-Aktualisierung ist ein Ereignisstrom (#033). Caddy erkennt das + # heute an `Content-Type: text/event-stream` und reicht von sich aus + # sofort durch – ausdruecklich gesetzt bleibt es auch dann richtig, wenn + # eine Fassung das einmal anders handhabt. Ohne sofortiges Durchreichen + # sammelt der Proxy den Strom, und die Seite sieht aus, als sei sie + # haengen geblieben. + flush_interval -1 } } ``` @@ -102,9 +110,16 @@ server { ssl_certificate /etc/letsencrypt/live/laufzettel.firma.de/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/laufzettel.firma.de/privkey.pem; - # Der CSV-Import schickt die Datei als Text durch eine Server Action. nginx - # laesst von sich aus nur 1 MB durch und waere damit die erste Wand. - client_max_body_size 8m; + # Der CSV-Import schickt die Datei als Text durch eine Server Action, und + # seit #032 haengt jemand Fotos an Schritte. nginx laesst von sich aus nur + # 1 MB durch und waere damit die erste Wand. + # + # **Muss ueber `ANHANG_MAX_MB` liegen.** Steht dort die Vorgabe 10, dann + # reichen 8m nicht: ein 10-MB-Foto scheitert an nginx mit „413 Request + # Entity Too Large“, und die Meldung der Anwendung („Erlaubt sind hoechstens + # 10,0 MB“) kommt nie zum Zuge. Wer die Grenze in der Anwendung anhebt, + # hebt sie hier mit an — mit etwas Luft fuer den Rahmen des Formulars. + client_max_body_size 12m; location / { proxy_pass http://127.0.0.1:3000; @@ -117,12 +132,22 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $remote_addr; - # Der Lauf fragt seinen Zustand alle zehn Sekunden nach; 90 s sind - # reichlich und fangen einen langsamen Verzeichnis-Abgleich mit ab. + # Die Abarbeitungsansicht haelt seit #033 einen **offenen** Strom auf + # /api/laeufe//zustand. Der Server schickt alle 25 s einen + # Herzschlag, damit nginx die stille Verbindung nicht schliesst; 90 s + # sind reichlich dafuer und fangen einen langsamen Verzeichnis-Abgleich + # mit ab. **Unter 30 s darf dieser Wert nicht fallen**, sonst reisst der + # Strom zwischen zwei Herzschlaegen immer wieder ab. proxy_read_timeout 90s; - # Next liefert Seiten stroemend aus. Ohne das haelt nginx die Antwort - # zusammen, und die Seite erscheint spuerbar spaeter auf einmal. + # Next liefert Seiten stroemend aus, und die Live-Aktualisierung ist ein + # Ereignisstrom. Ohne das haelt nginx die Antwort zusammen: die Seite + # erscheint spuerbar spaeter auf einmal, und vom Strom kommt gar nichts + # an — sie sieht dann aus, als sei sie haengen geblieben. + # + # Die Anwendung schickt zusaetzlich `X-Accel-Buffering: no` auf der + # Antwort des Stroms. Das wirkt auch, wenn hier `on` stuende, und deckt + # genau diesen einen Pfad ab; die Zeile hier gilt fuer alles andere. proxy_buffering off; } } diff --git a/docs/devlog.md b/docs/devlog.md index 8f7f9aa..0815897 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -31,12 +31,13 @@ als Unterpunkte mit Aufgabe und Dauer. | #030 | M17 – Wiederverwendbare Bausteine | 2 h 25 min | | #031 | M18 – Abteilungsspezifische Zusatzabschnitte | 1 h 35 min | | #032 | M19 – Anhänge und Fotos an Schritten | 3 h 05 min | -| | **Summe** | **48 h 25 min** | +| #033 | M20 – Live-Aktualisierung per SSE statt Polling | 1 h 40 min | +| | **Summe** | **50 h 05 min** | | Meilenstein | Tatsächlich | Status | |---|---|---| | v0.1.0 | 35 h 20 min | veröffentlicht | -| v0.2.0 | 13 h 05 min | in Arbeit | +| v0.2.0 | 14 h 45 min | in Arbeit | --- @@ -1176,3 +1177,65 @@ und nicht nach jeder einzelnen — das ist der Grund, warum er alles auf einmal großen verbliebenen. - Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host. - **#027** Die Editor-Abläufe lassen ihre Vorlagen in der Entwicklungsdatenbank stehen. + +--- + +## 2026-10-05 · Sitzung 18 · 09:00–10:40 + +**Issue:** #033 (M20) +**Version:** 0.2.0 (unveröffentlicht) + +### Erledigt + +- **Die Live-Aktualisierung läuft über einen Ereignisstrom.** Vier Trigger schicken + `NOTIFY lauf_geaendert, ''`, eine LISTEN-Verbindung je Prozess hört zu, der Route Handler + schiebt weiter. Gemessen: der Haken ist nach **485 ms** beim Zuschauer (Handy: 487 ms), vorher + bis zu zehn Sekunden. +- **Das Polling bleibt als Rückfall** — an derselben Adresse, unterschieden nur am `Accept`-Kopf. +- 1266 Vitest-Tests (vorher 1247), dazu `tests/e2e/live.spec.ts` mit zwei Browser-Kontexten. +- Der Herzschlag ist **echt über 70 Sekunden** gefahren: `readyState` blieb `OPEN`, ein einziges + Ereignis, kein Neuaufbau. + +### Entscheidungen + +- **Das Ereignis kommt aus der Datenbank, nicht aus dem Dienst.** Ein Ereignis, das der Service + verschickt, entsteht nur auf dem Weg durch den Service — ein Trigger sieht auch den Seed, eine + Migration und die Reparatur per `psql`. Und `NOTIFY` erreicht jede Instanz, ohne einen zweiten + Dienst wie Redis. +- **Ein Handler für beide Wege.** Damit ist „das Polling bleibt als Rückfall, nicht als zweite + Wahrheit“ keine Absichtserklärung, sondern Bauart: eine Rechteprüfung, ein Lesemodell, eine + Nutzlast. +- **Die Obergrenze braucht keinen Sonderfall.** Ist sie erreicht, antwortet der Handler gewöhnlich + mit JSON; `EventSource` meldet darauf einen Fehler und versucht es nicht wieder, und der Client + fällt von sich aus zurück. Kein eigener Fehlercode. +- **Keine Verbindung aus dem Prisma-Pool.** Ein Pool gibt Verbindungen zurück und verteilt sie neu; + ein `LISTEN` darauf gilt danach für eine fremde Anfrage oder für niemanden. +- **Bei versteckter Lasche wird der Strom geschlossen**, nicht bloß ignoriert — sonst belegte jede + Hintergrundlasche genau die Verbindung, die die Obergrenze schützen soll. + +### Probleme + +- **Das Issue hätte einen Rückschritt gebaut.** Es nannte Trigger auf `Schrittergebnis` und `Lauf`. + `laufZustand` trägt aber auch die Zahl der Kommentare, und seit #032 gibt es Anhänge: der Strom + sieht **nur**, was gemeldet wird, während das Polling den ganzen Stand verglich. Mit den zwei + Triggern aus dem Issue wäre der Kommentar des Kollegen nie angekommen — und die Seite hätte + ausgesehen wie immer. Das ist derselbe Fehlertyp wie in #032: eine Zusage, die niemand prüft. + Jetzt melden vier Tabellen, `laufZustand` trägt `anzahlAnhaenge`, und beide Tests zählen namentlich + auf, was dazugehört. +- **Zwei Fehler in `reverse-proxy.md`, beide von vorgestern.** `client_max_body_size 8m` bei + `ANHANG_MAX_MB=10`: ein 10-MB-Foto wäre an nginx mit 413 gescheitert, und die eigene Meldung + („Erlaubt sind höchstens 10,0 MB“) wäre nie erschienen. Dazu ein Kommentar, der noch das alte + Polling beschrieb. **Gefunden beim Schreiben der Doku, nicht von einem Test** — Dateien, die ein + Dritter liest, prüft hier niemand. +- **`## 5e` war in `docs/architektur.md` schon belegt.** Statt alles umzunummerieren und Verweise zu + riskieren, hängt der neue Text als `###` unter 5d. +- **Zwei eigene Testfehler, keine Produktfehler:** `schritte` zählte die „erledigt“-Knöpfe der + ganzen Seite (34) statt die des ersten Abschnitts (5), und ein `const` nach einem `?.`-Guard + verlor in der Closure seine Verengung. Beide Male war die Meldung eindeutig. + +### Nächster Schritt + +- **M21 (#034)** Benachrichtigungen per Mail und Webhook — baut direkt auf den Ereignissen auf, die + jetzt stehen. Danach **M22 (#035)** Selbstauskunft. +- **M15 (#028)** Graph-Quelle und **M16 (#029)** SCIM sind die beiden großen verbliebenen. +- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host. diff --git a/docs/issues/033-sse-live.md b/docs/issues/033-sse-live.md index 50b592c..bf6b34f 100644 --- a/docs/issues/033-sse-live.md +++ b/docs/issues/033-sse-live.md @@ -1,10 +1,10 @@ # #033 M20 – Live-Aktualisierung per SSE statt Polling -**Status:** offen +**Status:** erledigt **Im Tracker:** [#53](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/53) **Meilenstein:** v0.2.0 **Labels:** phase-2, backend, frontend -**Geschätzt:** 6 h **Tatsächlich:** – +**Geschätzt:** 6 h **Tatsächlich:** 1 h 40 min ## Ziel @@ -17,36 +17,78 @@ Die Vorleistung steht: das Polling liegt in genau einem Route Handler ## Zu entscheiden -- [ ] **`LISTEN`/`NOTIFY` in Postgres als Signal** — Empfehlung. Ein Trigger auf `Schrittergebnis` +- [x] **`LISTEN`/`NOTIFY` in Postgres als Signal** — Empfehlung. Ein Trigger auf `Schrittergebnis` und `Lauf` schickt `NOTIFY lauf_geaendert, ''`; der Route Handler hält eine Verbindung und schiebt weiter. Damit funktioniert es auch mit mehreren Instanzen der Anwendung, ohne einen zweiten Dienst. Die Alternative — der Server fragt selbst alle zwei Sekunden — wäre dasselbe Polling, nur an anderer Stelle. -- [ ] **Polling bleibt als Rückfall**, nicht als zweite Wahrheit: derselbe Handler antwortet weiter +- [x] **Polling bleibt als Rückfall**, nicht als zweite Wahrheit: derselbe Handler antwortet weiter auf eine gewöhnliche Anfrage. Wer kein `EventSource` hat oder hinter einem Proxy sitzt, der Ströme puffert, bekommt den alten Weg. Ein Rückfall, der nie geprüft wird, ist keiner — deshalb ein Test je Weg. ## Aufgaben -- [ ] Trigger und `NOTIFY` in einer handgeschriebenen Migration, mit dem üblichen Vermerk (Prisma +- [x] Trigger und `NOTIFY` in einer handgeschriebenen Migration, mit dem üblichen Vermerk (Prisma kennt Trigger nicht und will sie löschen) -- [ ] Route Handler liefert `text/event-stream`, schickt alle 25 s einen Kommentar als Herzschlag +- [x] Route Handler liefert `text/event-stream`, schickt alle 25 s einen Kommentar als Herzschlag (sonst schließen Proxys die Verbindung) und beendet sauber bei `abort` -- [ ] **Der Handler prüft Recht und Mandant selbst** — ein offener Strom ist ein Leseweg wie jeder +- [x] **Der Handler prüft Recht und Mandant selbst** — ein offener Strom ist ein Leseweg wie jeder andere (CLAUDE.md: Route Handler deckt die Hülle nicht ab) -- [ ] Client: `EventSource` mit Wiederverbindung; bei versteckter Lasche pausieren wie heute; bei +- [x] Client: `EventSource` mit Wiederverbindung; bei versteckter Lasche pausieren wie heute; bei `error` auf Polling zurückfallen und das **einmal** melden, nicht bei jedem Versuch -- [ ] `docs/betrieb/reverse-proxy.md`: `proxy_buffering off` für nginx, `flush_interval -1` für +- [x] `docs/betrieb/reverse-proxy.md`: `proxy_buffering off` für nginx, `flush_interval -1` für Caddy — ohne das kommt kein Ereignis an, und die Seite sieht aus, als sei sie hängen geblieben -- [ ] Obergrenze für gleichzeitige Ströme je Instanz, damit ein Browser mit vierzig Tabs die +- [x] Obergrenze für gleichzeitige Ströme je Instanz, damit ein Browser mit vierzig Tabs die Verbindungen nicht aufbraucht ## Akzeptanzkriterien -- [ ] Test: zwei Sitzungen am selben Lauf — hakt die eine ab, sieht die andere es in unter einer +- [x] Test: zwei Sitzungen am selben Lauf — hakt die eine ab, sieht die andere es in unter einer Sekunde, ohne neu zu laden -- [ ] Test: ohne Recht wird der Strom abgewiesen, bevor das erste Ereignis fließt -- [ ] Test: ein Lauf eines fremden Kunden liefert keinen Strom -- [ ] Test: der Rückfall auf Polling funktioniert — gefahren mit abgeschaltetem `EventSource` -- [ ] Der Herzschlag kommt an: eine Verbindung überlebt 60 s ohne Änderung +- [x] Test: ohne Recht wird der Strom abgewiesen, bevor das erste Ereignis fließt +- [x] Test: ein Lauf eines fremden Kunden liefert keinen Strom +- [x] Test: der Rückfall auf Polling funktioniert — gefahren mit abgeschaltetem `EventSource` +- [x] Der Herzschlag kommt an: eine Verbindung überlebt 60 s ohne Änderung + +## Beim Umsetzen dazugekommen + +- **Es wäre ein Rückschritt geworden.** Das Issue nannte Trigger auf `Schrittergebnis` und `Lauf`. + `laufZustand` trägt aber auch die Zahl der **Kommentare** — das Polling verglich alle zehn + Sekunden den ganzen Stand und sah sie damit, ein Strom sieht nur, was gemeldet wird. Mit den zwei + Triggern aus dem Issue wäre der Kommentar des Kollegen **nie** angekommen, und niemandem wäre + etwas aufgefallen: die Seite sieht aus wie immer. Jetzt melden vier Tabellen, und + `tests/datenbankregeln.test.ts` zählt sie namentlich auf. Gegenprobe gefahren: ohne die Trigger auf + `Kommentar` und `Anhang` wird `tests/lauf-ereignisse.test.ts` rot. +- **`laufZustand` hat `anzahlAnhaenge` bekommen.** Anhänge gibt es seit #032, im Zustand standen sie + nicht. Ein Ereignis ohne Entsprechung im Zustand löst nichts aus, weil der Client vergleicht, + bevor er neu lädt — ein angehängtes Foto wäre also auch mit Trigger unsichtbar geblieben. +- **Ein Handler für beide Wege, unterschieden am `Accept`-Kopf.** So steht „Polling bleibt als + Rückfall, nicht als zweite Wahrheit“ nicht nur im Issue: es ist dieselbe Rechteprüfung, dasselbe + Lesemodell, dieselbe Nutzlast. Ein zweiter Endpunkt wäre eine zweite Stelle, an der jemand + `rechtErforderlich` vergessen kann. +- **Die Obergrenze braucht keinen Sonderfall.** Ist sie erreicht, antwortet der Handler gewöhnlich + mit JSON. `EventSource` verlangt `text/event-stream`, meldet einen Fehler und versucht es **nicht + wieder** — der Client fällt damit von sich aus auf Polling zurück. Kein eigener Fehlercode, kein + zweiter Pfad. +- **Eine LISTEN-Verbindung je Prozess, nicht je Strom** — und ausdrücklich **nicht** aus dem + Prisma-Pool: ein Pool gibt Verbindungen zurück und verteilt sie neu, ein `LISTEN` darauf gilt + danach für eine fremde Anfrage oder für niemanden. +- **Nach einem Verbindungsabbruch werden alle Zuhörer geweckt.** `NOTIFY` hat keinen + Zwischenspeicher; was während des Abbruchs passierte, ist verloren. Lieber ein Abruf zu viel als + ein stiller Stand, der falsch ist. +- **Bei versteckter Lasche wird der Strom geschlossen**, nicht bloß ignoriert. Eine offene + Verbindung je Hintergrundlasche ist genau das, was die Obergrenze verhindern soll. +- **Zwei Fehler in `docs/betrieb/reverse-proxy.md` gefunden, beide aus #032:** + `client_max_body_size` stand auf **8m**, während `ANHANG_MAX_MB` 10 erlaubt — ein 10-MB-Foto wäre + an nginx mit „413“ gescheitert, und die freundliche Meldung der Anwendung wäre nie zum Zuge + gekommen. Jetzt 12m, mit dem Hinweis, dass beide Werte zusammengehören. Dazu beschrieb der + Kommentar am `proxy_read_timeout` noch das alte Polling. +- **Der Herzschlag ist echt über 70 Sekunden gefahren**, nicht nur im Test mit verkürztem Intervall: + `readyState` blieb `OPEN`, ein einziges Zustands-Ereignis, kein Fehler, kein Neuaufbau. +- **Gemessen statt behauptet:** der Haken war nach **485 ms** beim Zuschauer (Desktop) und nach + **487 ms** am Handy. Mit abgeschaltetem `EventSource` dauert derselbe Ablauf rund 13 Sekunden — + das ist zugleich der Nachweis, dass der Strom wirklich benutzt wird und nicht heimlich gepollt + wird. +- **Der Testname versprach `tests/e2e/live.spec.ts`**, also gibt es die Datei. Zwei Browser-Kontexte, + und für den Rückfall wird dem Zuschauer `EventSource` vor dem Laden weggenommen. diff --git a/docs/issues/README.md b/docs/issues/README.md index abdcd6d..0af7772 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -37,7 +37,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac | #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | erledigt | v0.2.0 | 12 h | 2 h 25 min | | #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | erledigt | v0.2.0 | 8 h | 1 h 35 min | | #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | erledigt | v0.2.0 | 10 h | 3 h 05 min | -| #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | offen | v0.2.0 | 6 h | – | +| #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | erledigt | v0.2.0 | 6 h | 1 h 40 min | | #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | offen | v0.2.0 | 10 h | – | | #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | offen | v0.2.0 | 8 h | – | -| | **Summe** | | | **229 h** | **48 h 25 min** | +| | **Summe** | | | **229 h** | **50 h 05 min** | diff --git a/prisma/migrations/20261005090000_lauf_ereignisse/migration.sql b/prisma/migrations/20261005090000_lauf_ereignisse/migration.sql new file mode 100644 index 0000000..9c40b4d --- /dev/null +++ b/prisma/migrations/20261005090000_lauf_ereignisse/migration.sql @@ -0,0 +1,84 @@ +-- --------------------------------------------------------------------------- +-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die +-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes +-- (Archivsuche), die partiellen eindeutigen Indizes und alle Trigger: es kennt +-- sie nicht, weil sie sich im Schema nicht ausdruecken lassen. Diese +-- Loeschbefehle sind bewusst gestrichen. +-- +-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind – +-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf. +-- --------------------------------------------------------------------------- + +-- Live-Aktualisierung per SSE statt Polling (#033). +-- +-- Die Datenbank sagt selbst, wenn sich an einem Lauf etwas geaendert hat: +-- `NOTIFY lauf_geaendert, ''`. Der Route Handler haelt eine +-- LISTEN-Verbindung und schiebt weiter. +-- +-- **Warum in der Datenbank und nicht im Dienst.** Ein Ereignis, das der Service +-- verschickt, entsteht nur auf dem Weg durch den Service. Die Regeln dieses +-- Projekts stehen aber absichtlich auch in der Datenbank, weil jemand an der +-- Service-Schicht vorbei schreiben kann — der Seed, eine Migration, eine +-- Reparatur per psql. Ein Trigger sieht all das. +-- +-- **Warum es mit mehreren Instanzen funktioniert.** NOTIFY geht an alle +-- Verbindungen, die LISTEN auf dem Kanal haben, also an jede Instanz der +-- Anwendung. Ein zweiter Dienst (Redis, ein Nachrichtenbus) waere dafuer nicht +-- noetig. +-- +-- **Zugestellt wird erst beim COMMIT.** Das ist hier genau richtig: ein +-- Abonnent, der eine noch nicht festgeschriebene Aenderung meldet, wuerde die +-- Seite mit Daten neu bauen lassen, die es gleich wieder nicht gibt. + +CREATE OR REPLACE FUNCTION laufzettel_lauf_geaendert() RETURNS trigger AS $$ +DECLARE + betroffener TEXT; +BEGIN + /* + * Bei DELETE gibt es kein NEW. Der Kanalname ist fest, die Nutzlast nicht – + * deshalb pg_notify() und nicht NOTIFY, das keine Variablen nimmt. + */ + IF TG_OP = 'DELETE' THEN + betroffener := OLD."laufId"; + ELSE + betroffener := NEW."laufId"; + END IF; + + IF betroffener IS NOT NULL THEN + PERFORM pg_notify('lauf_geaendert', betroffener); + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +-- Derselbe Rumpf fuer die Laufzeile selbst: dort heisst der Schluessel "id". +CREATE OR REPLACE FUNCTION laufzettel_lauf_geaendert_selbst() RETURNS trigger AS $$ +BEGIN + PERFORM pg_notify('lauf_geaendert', NEW."id"); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +/* + * **Welche Tabellen melden, haengt daran, was `laufZustand` liefert** — nicht + * daran, was sich "irgendwie" aendert. Das Lesemodell traegt Status, + * Schrittzustaende, die Zahl der Kommentare und (seit #033) die Zahl der + * Anhaenge. Wuerde eine davon nicht melden, waere der Strom gegenueber dem + * Polling, das alle zehn Sekunden vollstaendig vergleicht, ein **Rueckschritt**: + * ein neuer Kommentar kaeme nie an. + */ +CREATE TRIGGER "Lauf_geaendert_melden" + AFTER UPDATE ON "Lauf" + FOR EACH ROW EXECUTE FUNCTION laufzettel_lauf_geaendert_selbst(); + +CREATE TRIGGER "Schrittergebnis_geaendert_melden" + AFTER INSERT OR UPDATE OR DELETE ON "Schrittergebnis" + FOR EACH ROW EXECUTE FUNCTION laufzettel_lauf_geaendert(); + +CREATE TRIGGER "Kommentar_geaendert_melden" + AFTER INSERT OR UPDATE OR DELETE ON "Kommentar" + FOR EACH ROW EXECUTE FUNCTION laufzettel_lauf_geaendert(); + +CREATE TRIGGER "Anhang_geaendert_melden" + AFTER INSERT OR DELETE ON "Anhang" + FOR EACH ROW EXECUTE FUNCTION laufzettel_lauf_geaendert(); diff --git a/src/app/api/laeufe/[id]/zustand/route.ts b/src/app/api/laeufe/[id]/zustand/route.ts index 643ce1f..b24ca85 100644 --- a/src/app/api/laeufe/[id]/zustand/route.ts +++ b/src/app/api/laeufe/[id]/zustand/route.ts @@ -1,21 +1,41 @@ import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; import { laufZustand } from "@/server/queries/laeufe"; +import { HERZSCHLAG, herzschlagMs, maxStroeme, sseEreignis, willStrom } from "@/lib/sse"; +import { aufLaufHoeren, stroemeOffen } from "@/server/lauf-ereignisse"; /** - * Der Zustand eines Laufs fuer die Aktualisierung alle zehn Sekunden (#013). + * Der Zustand eines Laufs – als Strom oder als einzelne Antwort (#013, #033). * * **Route Handler pruefen selbst.** Die Huelle unter `src/app/(app)/layout.tsx` * deckt sie nicht ab — sie liegt in einem anderen Zweig des Baums, und der Proxy - * sieht nur, **ob** ein Cookie da ist, nie was drinsteht (CLAUDE.md). + * sieht nur, **ob** ein Cookie da ist, nie was drinsteht (CLAUDE.md). Ein + * offener Strom ist ein Leseweg wie jeder andere; geprueft wird **bevor** das + * erste Ereignis fliesst. + * + * **Eine Adresse fuer beide Wege.** `EventSource` schickt + * `Accept: text/event-stream`, sonst kommt eine gewoehnliche Anfrage — dieselbe + * Rechtepruefung, dasselbe Lesemodell, dieselbe Nutzlast. Ein zweiter Endpunkt + * waere eine zweite Stelle, an der jemand das Recht vergessen kann, und zwei + * Wahrheiten, die auseinanderlaufen. Das Polling bleibt damit ein echter + * Rueckfall und nicht ein zweiter Codepfad, den niemand mehr faehrt. * * Die Antwort enthaelt **keine Befehle**. Ein `{{generiertes_passwort}}` - * entsteht bei jeder Aufloesung neu; alle zehn Sekunden mitgeliefert, tauschte - * es dem Techniker das Passwort unter den Haenden weg, waehrend er es abtippt. - * Zurueck kommen nur Status und Schrittzustaende — genau das, was zwei Browser - * am selben Lauf voneinander wissen muessen. + * entsteht bei jeder Aufloesung neu; mitgeliefert, tauschte es dem Techniker das + * Passwort unter den Haenden weg, waehrend er es abtippt. Zurueck kommen nur + * Status, Schrittzustaende und die Zahl der Kommentare und Anhaenge — genau das, + * was zwei Browser am selben Lauf voneinander wissen muessen. */ + +/* + * Sammelfenster. Wer einen Abschnitt abschnittsweise abhakt, loest ein Dutzend + * NOTIFY in einer Sekunde aus; ohne Fenster waere das ein Dutzend Abfragen des + * Lesemodells je Zuschauer. 150 ms sind unter der Wahrnehmungsschwelle und + * fassen einen Schwung zusammen. + */ +const SAMMELFENSTER_MS = 150; + export async function GET( - _anfrage: Request, + anfrage: Request, { params }: { params: Promise<{ id: string }> }, ): Promise { let benutzerId: string; @@ -34,16 +54,127 @@ export async function GET( } const { id } = await params; - const zustand = await laufZustand(id, { id: benutzerId, rolle }); + const benutzer = { id: benutzerId, rolle }; + const zustand = await laufZustand(id, benutzer); if (!zustand) { // Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die // Unterscheidung waere schon eine Auskunft ueber fremde Kunden. return Response.json({ fehler: "Diesen Lauf gibt es nicht." }, { status: 404 }); } - return Response.json(zustand, { - // Nichts zwischenspeichern: die Antwort ist genau dann wertlos, wenn sie - // aus einem Zwischenspeicher kommt. - headers: { "Cache-Control": "no-store" }, + /* + * Kein Strom gewuenscht – oder keiner mehr frei. Bei erreichter Obergrenze + * antwortet der Handler **gewoehnlich**: `EventSource` verlangt + * `text/event-stream`, bekommt JSON, meldet `error` und versucht es nicht + * wieder. Der Client fuellt damit von sich aus auf Polling zurueck, ohne dass + * es dafuer einen Sonderfall oder einen Fehlercode braucht. + */ + if (!willStrom(anfrage.headers.get("accept")) || stroemeOffen() >= maxStroeme()) { + return Response.json(zustand, { + // Nichts zwischenspeichern: die Antwort ist genau dann wertlos, wenn sie + // aus einem Zwischenspeicher kommt. + headers: { "Cache-Control": "no-store" }, + }); + } + + const kodierer = new TextEncoder(); + let abmelden: (() => void) | undefined; + let herzschlag: ReturnType | undefined; + let sammler: ReturnType | undefined; + + const strom = new ReadableStream({ + async start(steuerung) { + let offen = true; + + function schicken(text: string): void { + if (!offen) return; + try { + steuerung.enqueue(kodierer.encode(text)); + } catch { + // Gegenseite ist weg, bevor `abort` ankam. + offen = false; + } + } + + function aufraeumen(): void { + if (!offen) return; + offen = false; + abmelden?.(); + if (herzschlag) clearInterval(herzschlag); + if (sammler) clearTimeout(sammler); + try { + steuerung.close(); + } catch { + // Schon geschlossen. + } + } + + async function standSchicken(): Promise { + if (!offen) return; + try { + const jetzt = await laufZustand(id, benutzer); + if (!jetzt) { + /* + * Lauf weg oder nicht mehr sichtbar — etwa weil jemandem der Kunde + * entzogen wurde. Der Strom endet; der Client holt sich an der + * gewoehnlichen Adresse die 404 und stellt ein. + */ + schicken(sseEreignis("ende", JSON.stringify({ grund: "nicht-sichtbar" }))); + aufraeumen(); + return; + } + schicken(sseEreignis("zustand", JSON.stringify(jetzt))); + } catch (fehler) { + console.error("Zustand fuer den Strom konnte nicht gelesen werden:", fehler); + } + } + + // Der erste Stand sofort, damit der Client einen Vergleichspunkt hat, + // ohne zusaetzlich zu pollen. + schicken(sseEreignis("zustand", JSON.stringify(zustand))); + + try { + abmelden = await aufLaufHoeren(id, () => { + if (sammler) return; + sammler = setTimeout(() => { + sammler = undefined; + void standSchicken(); + }, SAMMELFENSTER_MS); + }); + } catch (fehler) { + /* + * Keine LISTEN-Verbindung zu bekommen ist kein Grund, die Seite blind + * zu lassen: Strom beenden, der Client fuellt auf Polling zurueck. + */ + console.error("LISTEN fuer den Strom nicht moeglich:", fehler); + aufraeumen(); + return; + } + + herzschlag = setInterval(() => schicken(HERZSCHLAG), herzschlagMs()); + anfrage.signal.addEventListener("abort", aufraeumen); + }, + + cancel() { + abmelden?.(); + if (herzschlag) clearInterval(herzschlag); + if (sammler) clearTimeout(sammler); + }, + }); + + return new Response(strom, { + headers: { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-store, no-transform", + Connection: "keep-alive", + /* + * Fuer nginx: schaltet die Pufferung fuer **diese** Antwort ab, auch wenn + * in der Konfiguration `proxy_buffering on` steht. Ohne das sammelt nginx + * den Strom und gibt ihn erst am Ende heraus — die Seite sieht dann aus, + * als sei sie hangen geblieben. Die Einstellung steht trotzdem in + * `docs/betrieb/reverse-proxy.md`: diese Kopfzeile versteht nur nginx. + */ + "X-Accel-Buffering": "no", + }, }); } diff --git a/src/components/laeufe/lauf-aktualisierung.tsx b/src/components/laeufe/lauf-aktualisierung.tsx index 78ad3ec..fbd10fd 100644 --- a/src/components/laeufe/lauf-aktualisierung.tsx +++ b/src/components/laeufe/lauf-aktualisierung.tsx @@ -1,34 +1,45 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { RefreshCw } from "lucide-react"; /** - * Holt alle zehn Sekunden den Zustand des Laufs (#013). + * Haelt die Abarbeitungsansicht aktuell (#013, Strom seit #033). * * Damit sehen zwei Browser am selben Lauf die Arbeit des anderen — der Fall aus * dem Auftrag: zwei Techniker teilen sich ein Onboarding. * - * Vier Entscheidungen stecken darin: + * **Seit #033 ueber einen Strom**, nicht mehr alle zehn Sekunden. Die Datenbank + * meldet die Aenderung, der Server schiebt sie weiter, und der Haken des + * Kollegen steht in unter einer Sekunde auf dem anderen Schirm statt nach bis zu + * zehn. Das Polling bleibt als **Rueckfall** — an derselben Adresse, mit + * derselben Nutzlast. + * + * Fuenf Entscheidungen stecken darin: * * 1. **Pause bei verstecktem Tab.** Ein Lauf bleibt oft stundenlang offen im - * Hintergrund; ohne Pause waeren das 360 Abfragen je Stunde und Tab, fuer - * niemanden. + * Hintergrund. Der Strom wird dabei **geschlossen**, nicht nur ignoriert: + * eine offene Verbindung je Hintergrundlasche ist genau das, was die + * Obergrenze im Handler verhindern soll. * 2. **`router.refresh()` statt eigener Zustandsverwaltung.** Die Seite ist eine * Server-Komponente; ein Neuladen ihrer Daten ist genau das, was gebraucht * wird, und es gibt keine zweite Wahrheit im Browser. Aktualisiert wird nur, - * wenn sich wirklich etwas geaendert hat — sonst wuerde die Seite alle zehn - * Sekunden neu rendern und dabei Eingaben verlieren, an denen jemand tippt. + * wenn sich wirklich etwas geaendert hat — sonst wuerde die Seite bei jedem + * Ereignis neu rendern und dabei Eingaben verlieren, an denen jemand tippt. * 3. **Steht ein generiertes Passwort auf der Seite, wird nicht von selbst * neu geladen**, sondern gefragt. Das Passwort entsteht bei jedem Aufbau der * Seite neu; ein Neuladen im Hintergrund tauschte es dem Techniker zwischen - * Lesen und Einfuegen aus. Die Antwort des Pollings traegt deshalb keine - * Befehle (siehe `laufZustand`) — und diese Zusage waere nichts wert, wenn - * das Polling stattdessen die ganze Seite neu bauen liesse. + * Lesen und Einfuegen aus. Die Nutzlast traegt deshalb keine Befehle (siehe + * `laufZustand`) — und diese Zusage waere nichts wert, wenn die + * Aktualisierung stattdessen die ganze Seite neu bauen liesse. * 4. **Abbruch bei 401 und 404.** Eine abgelaufene Anmeldung oder ein * verschwundener Lauf sind kein Grund weiterzufragen. + * 5. **Der Rueckfall wird einmal gemeldet, nicht bei jedem Versuch.** Sonst + * schreibt eine Seite hinter einem puffernden Proxy im Minutentakt dieselbe + * Zeile ins Protokoll der Konsole. */ + /** * Was ein geaenderter Zustand ausloesen soll. * @@ -40,6 +51,22 @@ export function reaktionAufAenderung(passwortSichtbar: boolean): "neu-laden" | " return passwortSichtbar ? "fragen" : "neu-laden"; } +/** `EventSource.CLOSED`, ohne im Test ein `EventSource` zu brauchen. */ +export const STROM_GESCHLOSSEN = 2; + +/** + * Ist ein Fehler am Strom das Ende – oder nur eine Unterbrechung? + * + * `EventSource` verbindet sich nach einem Abbruch **selbst** wieder; dann steht + * `readyState` auf `CONNECTING`, und zurueckzufallen waere verfruehtes Aufgeben. + * Endgueltig ist nur `CLOSED` — so antwortet der Browser etwa, wenn die Antwort + * nicht `text/event-stream` ist. Genau das schickt der Handler bei erreichter + * Obergrenze, und genau dann soll gepollt werden. + */ +export function sollZurueckfallen(readyState: number): boolean { + return readyState === STROM_GESCHLOSSEN; +} + export function LaufAktualisierung({ laufId, passwortSichtbar = false, @@ -57,29 +84,46 @@ export function LaufAktualisierung({ const letzterStand = useRef(null); const [neues, setzeNeues] = useState(false); + /** + * Ein gelesener Stand: vergleichen, und nur bei echter Aenderung reagieren. + * + * Dieselbe Stelle fuer Strom und Polling. Waere der Vergleich zweimal + * geschrieben, koennten die beiden Wege unterschiedlich reagieren — und der + * Rueckfall waere dann kein Rueckfall, sondern ein zweites Verhalten. + */ + const standVerarbeiten = useCallback( + (stand: string): void => { + if (letzterStand.current !== null && letzterStand.current !== stand) { + if (reaktionAufAenderung(passwortSichtbar) === "fragen") setzeNeues(true); + else router.refresh(); + } + letzterStand.current = stand; + }, + [passwortSichtbar, router], + ); + useEffect(() => { - let abgebrochen = false; + const adresse = `/api/laeufe/${laufId}/zustand`; + let eingestellt = false; + let quelle: EventSource | undefined; let zeitgeber: ReturnType | undefined; + let polltJetzt = false; + let rueckfallGemeldet = false; + + /* ---------------- Polling: der Rueckfall ---------------- */ async function holen(): Promise { - if (abgebrochen || document.visibilityState === "hidden") { + if (eingestellt || document.visibilityState === "hidden") { plane(); return; } try { - const antwort = await fetch(`/api/laeufe/${laufId}/zustand`, { cache: "no-store" }); + const antwort = await fetch(adresse, { cache: "no-store" }); if (antwort.status === 401 || antwort.status === 404) { - abgebrochen = true; + eingestellt = true; return; } - if (antwort.ok) { - const stand = JSON.stringify(await antwort.json()); - if (letzterStand.current !== null && letzterStand.current !== stand) { - if (reaktionAufAenderung(passwortSichtbar) === "fragen") setzeNeues(true); - else router.refresh(); - } - letzterStand.current = stand; - } + if (antwort.ok) standVerarbeiten(JSON.stringify(await antwort.json())); } catch { // Netz weg, Rechner im Standby: einfach beim naechsten Mal wieder. } @@ -87,23 +131,99 @@ export function LaufAktualisierung({ } function plane(): void { - if (abgebrochen) return; + if (eingestellt || !polltJetzt) return; zeitgeber = setTimeout(() => void holen(), 10_000); } - void holen(); - // Kommt der Tab zurueck, sofort fragen statt bis zu zehn Sekunden zu warten. - const beiSichtbarkeit = () => { - if (document.visibilityState === "visible") void holen(); + function aufPollingUmstellen(grund: string): void { + if (eingestellt || polltJetzt) return; + polltJetzt = true; + stromSchliessen(); + if (!rueckfallGemeldet) { + rueckfallGemeldet = true; + // Einmal, nicht bei jedem Versuch – siehe Kopfkommentar. + console.info(`Live-Aktualisierung nicht moeglich (${grund}), es wird gepollt.`); + } + void holen(); + } + + /* ---------------- Strom: der Normalfall ---------------- */ + + function stromSchliessen(): void { + quelle?.close(); + quelle = undefined; + } + + function stromOeffnen(): void { + if (eingestellt || polltJetzt || quelle || typeof EventSource === "undefined") return; + const neu = new EventSource(adresse); + quelle = neu; + + neu.addEventListener("zustand", (ereignis) => { + standVerarbeiten((ereignis as MessageEvent).data); + }); + + // Der Server sagt, dass es nichts mehr zu sehen gibt – nicht weiterfragen. + neu.addEventListener("ende", () => { + eingestellt = true; + stromSchliessen(); + }); + + neu.addEventListener("error", () => { + if (sollZurueckfallen(neu.readyState)) aufPollingUmstellen("Strom geschlossen"); + // Sonst: `EventSource` verbindet selbst wieder, hier ist nichts zu tun. + }); + } + + /* ---------------- Sichtbarkeit ---------------- */ + + const beiSichtbarkeit = (): void => { + if (eingestellt) return; + if (document.visibilityState === "hidden") { + // Verbindung wirklich freigeben, nicht bloss ignorieren. + stromSchliessen(); + if (zeitgeber) clearTimeout(zeitgeber); + return; + } + /* + * Zurueck im Vordergrund: erst den Stand holen, dann wieder zuhoeren. + * Waehrend die Lasche weg war, kam keine Meldung an — ohne diesen Abruf + * stuende der alte Stand da, bis das naechste Mal jemand etwas aendert. + */ + if (polltJetzt) void holen(); + else { + stromOeffnen(); + void einmalHolen(); + } }; + + /** Ein einzelner Abruf, ohne das Polling einzuschalten. */ + async function einmalHolen(): Promise { + try { + const antwort = await fetch(adresse, { cache: "no-store" }); + if (antwort.status === 401 || antwort.status === 404) { + eingestellt = true; + stromSchliessen(); + return; + } + if (antwort.ok) standVerarbeiten(JSON.stringify(await antwort.json())); + } catch { + // Der Strom liefert den Stand ohnehin gleich mit. + } + } + + if (typeof EventSource === "undefined") aufPollingUmstellen("kein EventSource"); + else stromOeffnen(); + document.addEventListener("visibilitychange", beiSichtbarkeit); return () => { - abgebrochen = true; + eingestellt = true; + stromSchliessen(); if (zeitgeber) clearTimeout(zeitgeber); document.removeEventListener("visibilitychange", beiSichtbarkeit); }; - }, [laufId, router, passwortSichtbar]); + }, [laufId, standVerarbeiten]); // Ohne Passwort auf der Seite ist die Wirkung unsichtbar: sie erneuert sich. if (!neues) return null; diff --git a/src/lib/sse.ts b/src/lib/sse.ts new file mode 100644 index 0000000..83eb9df --- /dev/null +++ b/src/lib/sse.ts @@ -0,0 +1,79 @@ +/** + * Server-Sent Events: Format und Grenzen (#033). + * + * Framework-frei, weil beides geprueft werden muss, ohne eine Verbindung + * aufzubauen. Das Format ist winzig und hat genau eine Falle, die einen Strom + * still zerstoert — siehe `sseEreignis`. + */ + +/** Der Kanal, auf dem die Datenbank Aenderungen meldet. Muss zur Migration passen. */ +export const KANAL = "lauf_geaendert"; + +/** + * Ein Ereignis im SSE-Format. + * + * **Die Falle:** ein Zeilenumbruch in den Daten beendet das Feld. Steht in der + * Nutzlast ein `\n`, zerfaellt die Nachricht, und der Client bekommt Schrott + * oder gar nichts. Deshalb wird **jede** Zeile mit eigenem `data:` gesendet — + * so schreibt es die Spezifikation vor, und der Client fuegt sie mit `\n` + * wieder zusammen. + * + * Unsere Nutzlast ist JSON aus `JSON.stringify` ohne Einrueckung und hat heute + * nie einen Umbruch. „Hat heute nie“ ist aber keine Zusage, sondern ein Zufall: + * ein Lauftitel mit Umbruch, ein Notiztext, und der Strom reisst. + */ +export function sseEreignis(name: string, daten: string): string { + const zeilen = daten.split("\n").map((zeile) => `data: ${zeile}`); + return `event: ${name}\n${zeilen.join("\n")}\n\n`; +} + +/** + * Der Herzschlag: ein Kommentar, den der Client verwirft. + * + * Ohne ihn schliesst ein Reverse Proxy die stille Verbindung nach seiner + * Leerlaufzeit — nginx nach 60 s, und der Browser sieht nur einen Abbruch. + * Ein Kommentar (Zeile mit `:`) kostet zwei Bytes und haelt sie offen. + */ +export const HERZSCHLAG = ": hz\n\n"; + +const STANDARD_HERZSCHLAG_MS = 25_000; +const STANDARD_MAX_STROEME = 200; + +/** + * Abstand der Herzschlaege. + * + * 25 s, also unter der Leerlaufzeit von 60 s, die nginx und Caddy voreingestellt + * haben. Umstellbar, weil der Test nicht eine Minute warten soll, um zu sehen, + * dass ueberhaupt einer kommt. + */ +export function herzschlagMs(): number { + const roh = Number.parseInt(process.env["SSE_HERZSCHLAG_MS"]?.trim() ?? "", 10); + return Number.isFinite(roh) && roh > 0 ? roh : STANDARD_HERZSCHLAG_MS; +} + +/** + * Hoechstzahl gleichzeitig offener Stroeme je Instanz. + * + * Ein Strom belegt eine HTTP-Verbindung, solange die Lasche offen ist. Ohne + * Grenze belegt ein Haus mit vierzig offenen Laschen vierzig Verbindungen, und + * irgendwann nimmt der Server keine gewoehnliche Anfrage mehr an — die Seite + * waere dann nicht langsam, sondern tot. Ist die Grenze erreicht, antwortet der + * Handler gewoehnlich mit JSON, und der Client fuellt auf Polling zurueck. + */ +export function maxStroeme(): number { + const roh = Number.parseInt(process.env["SSE_MAX_STROEME"]?.trim() ?? "", 10); + return Number.isFinite(roh) && roh > 0 ? roh : STANDARD_MAX_STROEME; +} + +/** + * Will der Aufrufer einen Strom? + * + * `EventSource` schickt `Accept: text/event-stream` von sich aus — das ist der + * einzige Unterschied zwischen dem Strom und dem Polling, und deshalb liegen + * beide an **derselben** Adresse: eine Rechtepruefung, ein Lesemodell, eine + * Wahrheit. Ein zweiter Endpunkt waere eine zweite Stelle, an der jemand das + * Recht vergessen kann. + */ +export function willStrom(accept: string | null): boolean { + return (accept ?? "").split(",").some((teil) => teil.trim().startsWith("text/event-stream")); +} diff --git a/src/server/lauf-ereignisse.ts b/src/server/lauf-ereignisse.ts new file mode 100644 index 0000000..16f9e77 --- /dev/null +++ b/src/server/lauf-ereignisse.ts @@ -0,0 +1,195 @@ +import { Client } from "pg"; +import { KANAL } from "@/lib/sse"; + +/** + * Aenderungen an einem Lauf, wie die Datenbank sie meldet (#033). + * + * Die Trigger aus `20261005090000_lauf_ereignisse` schicken + * `NOTIFY lauf_geaendert, ''`. Hier haengt **eine** Verbindung je + * Prozess mit `LISTEN` daran und verteilt an die offenen Stroeme. + * + * **Eine Verbindung, nicht eine je Strom.** `LISTEN` belegt eine Verbindung + * dauerhaft; eine je Lasche waere bei vierzig Laschen vierzig belegte + * Datenbankverbindungen, und PostgreSQL erlaubt voreingestellt hundert + * insgesamt. Die Nutzlast sagt, welcher Lauf gemeint ist — das Verteilen + * passiert hier im Prozess. + * + * **Nicht aus dem Prisma-Pool.** Ein Pool gibt Verbindungen zurueck und + * verteilt sie neu; ein `LISTEN` darauf gilt nach der Rueckgabe fuer eine + * fremde Anfrage oder fuer niemanden. Deshalb ein eigener `pg.Client`. + */ + +type Rueckruf = () => void; + +type Register = { + readonly abonnenten: Map>; + verbindung: Client | undefined; + aufbau: Promise | undefined; + geschlossen: boolean; +}; + +/* + * An `globalThis`, aus demselben Grund wie der Prisma-Client in `src/lib/db.ts`: + * Hot Reload laedt das Modul neu und baute sonst bei jedem Speichern eine + * weitere LISTEN-Verbindung auf, ohne die alte zu schliessen. + */ +const global = globalThis as typeof globalThis & { laufzettelEreignisse?: Register }; + +function register(): Register { + global.laufzettelEreignisse ??= { + abonnenten: new Map(), + verbindung: undefined, + aufbau: undefined, + geschlossen: false, + }; + return global.laufzettelEreignisse; +} + +/** Alle Abonnenten eines Laufs wecken. */ +function wecken(laufId: string): void { + for (const rueckruf of register().abonnenten.get(laufId) ?? []) { + try { + rueckruf(); + } catch (fehler) { + // Ein kaputter Abonnent darf die anderen nicht mitnehmen. + console.error("Abonnent eines Lauf-Ereignisses hat geworfen:", fehler); + } + } +} + +/** Alle Abonnenten wecken, egal welcher Lauf. Nach einem Verbindungsabbruch. */ +function alleWecken(): void { + for (const laufId of [...register().abonnenten.keys()]) wecken(laufId); +} + +async function verbindungAufbauen(): Promise { + const eigen = register(); + const url = process.env["DATABASE_URL"]; + if (!url) throw new Error("DATABASE_URL ist nicht gesetzt. Siehe .env.example."); + + const client = new Client({ connectionString: url }); + + /* + * **Erst der Fehlerbehandler, dann verbinden.** Ein `error` auf einem Client + * ohne Behandler beendet in Node den ganzen Prozess — und eine Datenbank, die + * neu startet, ist der Normalfall, nicht die Ausnahme. + */ + client.on("error", (fehler) => { + console.error("LISTEN-Verbindung verloren:", fehler.message); + eigen.verbindung = undefined; + void wiederverbinden(); + }); + client.on("end", () => { + if (eigen.verbindung === client) { + eigen.verbindung = undefined; + void wiederverbinden(); + } + }); + client.on("notification", (nachricht) => { + if (nachricht.channel === KANAL && nachricht.payload) wecken(nachricht.payload); + }); + + await client.connect(); + await client.query(`LISTEN ${KANAL}`); + eigen.verbindung = client; + + /* + * **Nach einem Aufbau alle wecken.** Zwischen Abbruch und Wiederverbindung + * sind Meldungen verloren gegangen — NOTIFY hat keinen Zwischenspeicher. Wer + * jetzt nicht nachfragt, sieht den Haken des Kollegen nie. Lieber ein Abruf + * zu viel als ein stiller Stand, der falsch ist. + */ + alleWecken(); +} + +/** + * Nach einem Abbruch neu verbinden, mit wachsendem Abstand. + * + * Ohne Abstand liefe bei einer laenger weg gebliebenen Datenbank eine + * Endlosschleife, die nichts tut als Verbindungen zu versuchen. + */ +async function wiederverbinden(versuch = 1): Promise { + const eigen = register(); + if (eigen.geschlossen || eigen.abonnenten.size === 0) return; + + const wartezeit = Math.min(30_000, 500 * 2 ** (versuch - 1)); + await new Promise((fertig) => setTimeout(fertig, wartezeit)); + if (eigen.geschlossen || eigen.abonnenten.size === 0) return; + + try { + await verbindungAufbauen(); + } catch { + await wiederverbinden(versuch + 1); + } +} + +/** Sicherstellen, dass die eine Verbindung steht. Mehrfachaufrufe teilen sich den Aufbau. */ +async function verbindungSicherstellen(): Promise { + const eigen = register(); + eigen.geschlossen = false; + if (eigen.verbindung) return; + // Laufender Aufbau: anhaengen statt einen zweiten starten. + eigen.aufbau ??= verbindungAufbauen().finally(() => { + eigen.aufbau = undefined; + }); + await eigen.aufbau; +} + +/** + * Auf Aenderungen eines Laufs hoeren. + * + * Gibt die Funktion zurueck, mit der das Abonnement endet. Sie **muss** gerufen + * werden, wenn der Strom schliesst — sonst waechst das Register mit jeder + * geschlossenen Lasche weiter, und der Zaehler fuer die Obergrenze erzaehlt + * Unsinn. + */ +export async function aufLaufHoeren(laufId: string, rueckruf: Rueckruf): Promise<() => void> { + const eigen = register(); + const menge = eigen.abonnenten.get(laufId) ?? new Set(); + menge.add(rueckruf); + eigen.abonnenten.set(laufId, menge); + + try { + await verbindungSicherstellen(); + } catch (fehler) { + // Aufraeumen, sonst zaehlt ein nie aufgebauter Strom weiter mit. + menge.delete(rueckruf); + if (menge.size === 0) eigen.abonnenten.delete(laufId); + throw fehler; + } + + let beendet = false; + return () => { + if (beendet) return; + beendet = true; + menge.delete(rueckruf); + if (menge.size === 0) eigen.abonnenten.delete(laufId); + }; +} + +/** + * Zahl der offenen Stroeme in diesem Prozess. + * + * Grundlage der Obergrenze. Bewusst die Summe der Abonnenten und nicht die Zahl + * der Laeufe: zwei Techniker an einem Lauf sind zwei Verbindungen. + */ +export function stroemeOffen(): number { + let summe = 0; + for (const menge of register().abonnenten.values()) summe += menge.size; + return summe; +} + +/** + * Die Verbindung schliessen und alle Abonnements verwerfen. + * + * Fuer den geordneten Abbau und fuer Tests, die nicht auf einer offenen + * Verbindung sitzen bleiben sollen. + */ +export async function ereignisseSchliessen(): Promise { + const eigen = register(); + eigen.geschlossen = true; + eigen.abonnenten.clear(); + const client = eigen.verbindung; + eigen.verbindung = undefined; + if (client) await client.end().catch(() => undefined); +} diff --git a/src/server/queries/laeufe.ts b/src/server/queries/laeufe.ts index 9ecaf20..4580741 100644 --- a/src/server/queries/laeufe.ts +++ b/src/server/queries/laeufe.ts @@ -456,6 +456,16 @@ export type LaufZustand = { * (Reviewbefund zu #013). */ readonly anzahlKommentare: number; + /** + * Zahl der Anhaenge (#033). + * + * Aus demselben Grund wie die Kommentare: ein Foto, das der Kollege gerade an + * einen Schritt gehaengt hat, blieb sonst unsichtbar. Seit #032 gibt es + * Anhaenge, und seit #033 meldet die Datenbank ihre Aenderung — ein Ereignis + * ohne Entsprechung im Zustand loeste aber nichts aus, weil der Client + * vergleicht, bevor er neu laedt. + */ + readonly anzahlAnhaenge: number; readonly geaendertAm: Date; readonly schritte: readonly { readonly schrittId: string; @@ -487,7 +497,7 @@ export async function laufZustand( select: { status: true, geaendertAm: true, - _count: { select: { kommentare: true } }, + _count: { select: { kommentare: true, anhaenge: true } }, ergebnisse: { select: { vorlagenSchrittId: true, @@ -504,6 +514,7 @@ export async function laufZustand( return { status: lauf.status, anzahlKommentare: lauf._count.kommentare, + anzahlAnhaenge: lauf._count.anhaenge, geaendertAm: lauf.geaendertAm, anzahlPflichtOffen: lauf.ergebnisse.filter( (ergebnis) => ergebnis.vorlagenSchritt.pflicht && !istVomTisch(ergebnis.zustand), diff --git a/tests/datenbankregeln.test.ts b/tests/datenbankregeln.test.ts index 29d9c76..ba49350 100644 --- a/tests/datenbankregeln.test.ts +++ b/tests/datenbankregeln.test.ts @@ -56,7 +56,11 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => { }); it("hat die Trigger, die eine veröffentlichte Version schützen", async () => { - expect(await namen(`SELECT tgname AS name FROM pg_trigger WHERE NOT tgisinternal`)).toEqual([ + expect( + await namen( + `SELECT tgname AS name FROM pg_trigger WHERE NOT tgisinternal AND tgname NOT LIKE '%_melden'`, + ), + ).toEqual([ "BausteinSchritt_unveraenderlich", "BausteinVersion_status_einbahn", "VorlagenAbschnitt_baustein_veroeffentlicht", @@ -67,6 +71,24 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => { ]); }); + it("hat die Trigger, die eine Änderung am Lauf melden", async () => { + /* + * Ohne sie bleibt die Live-Aktualisierung still, und zwar **lautlos**: die + * Seite sieht aus wie immer, nur sieht niemand mehr die Arbeit des anderen. + * Welche Tabellen dabei sind, haengt an dem, was `laufZustand` liefert — + * fehlte `Kommentar` oder `Anhang`, waere der Strom gegenueber dem alten + * Polling ein Rueckschritt (#033). + */ + expect( + await namen(`SELECT tgname AS name FROM pg_trigger WHERE NOT tgisinternal AND tgname LIKE '%_melden'`), + ).toEqual([ + "Anhang_geaendert_melden", + "Kommentar_geaendert_melden", + "Lauf_geaendert_melden", + "Schrittergebnis_geaendert_melden", + ]); + }); + it("hat die partiellen Eindeutigkeitsindizes", async () => { expect( await namen( diff --git a/tests/e2e/live.spec.ts b/tests/e2e/live.spec.ts new file mode 100644 index 0000000..6e4bbf4 --- /dev/null +++ b/tests/e2e/live.spec.ts @@ -0,0 +1,141 @@ +import { expect, test, type Page } from "@playwright/test"; +import "dotenv/config"; +import { prisma } from "@/lib/db"; +import { SEED_TAG, SEED_VORLAGE, anmelden, kundeWaehlen, variablenFuellen, vorlageWaehlen } from "./helfer"; + +/** + * Zwei Sitzungen an einem Lauf (#033). + * + * Das ist das Abnahmekriterium, das sich nur hier pruefen laesst: **hakt die + * eine ab, sieht die andere es ohne neu zu laden.** Mit dem Polling aus #013 war + * das richtig, aber bis zu zehn Sekunden langsam; seit dem Strom kommt es + * sofort. + * + * Und der zweite Ablauf prueft den Rueckfall. Ein Rueckfall, der nie gefahren + * wird, ist keiner — dieses Projekt hat genau dafuer schon bezahlt (#010: eine + * Oberflaeche, die serverseitig richtig aussah und nie hydrierte). + */ + +const STEMPEL = Date.now(); + +/** Legt einen Lauf an, beginnt ihn und gibt seine Adresse zurueck. */ +async function laufVorbereiten(seite: Page, titel: string): Promise { + await seite.goto("/laeufe/starten"); + await vorlageWaehlen(seite, SEED_VORLAGE); + const geraet = await prisma.geraet.findUniqueOrThrow({ + where: { tagCode: SEED_TAG }, + select: { id: true, kundeId: true }, + }); + await kundeWaehlen(seite, geraet.kundeId); + await seite.selectOption('select[name="geraetId"]', geraet.id); + await seite.locator("details summary", { hasText: "Titel, Ticket" }).click(); + await seite.getByLabel("Titel", { exact: true }).fill(titel); + await variablenFuellen(seite); + await seite.getByRole("button", { name: "Lauf anlegen" }).click(); + await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 30_000 }); + + await seite.getByRole("button", { name: "Lauf beginnen" }).click(); + await expect(seite.getByRole("button", { name: "Lauf abschließen" })).toBeVisible(); + return new URL(seite.url()).pathname; +} + +/** + * Den ersten Abschnitt aufklappen und Kopf **und** Bereich zurueckgeben. + * + * Gezaehlt wird spaeter im Bereich, nicht auf der Seite: die Vorlage hat 34 + * Schritte in sieben Abschnitten, und `0 von 34` steht in keinem Kopf. + */ +async function ersterAbschnitt(seite: Page) { + const bereich = seite + .locator("section") + .filter({ has: seite.locator("button[aria-expanded]") }) + .first(); + const kopf = bereich.locator("button[aria-expanded]").first(); + if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click(); + return { kopf, bereich }; +} + +test.describe("Live-Aktualisierung", () => { + test("die zweite Sitzung sieht den Haken der ersten, ohne neu zu laden", async ({ page, browser }) => { + test.slow(); + await anmelden(page); + const pfad = await laufVorbereiten(page, `ZZ E2E Live ${STEMPEL}`); + + // Zweite Sitzung: eigener Kontext, eigenes Cookie, eigener Strom. + const zweiter = await browser.newContext(); + const zuschauer = await zweiter.newPage(); + try { + await anmelden(zuschauer); + await zuschauer.goto(pfad); + + const { kopf: kopfA, bereich: bereichA } = await ersterAbschnitt(page); + const { kopf: kopfB } = await ersterAbschnitt(zuschauer); + + const schritte = await bereichA.getByRole("button", { name: "erledigt", exact: true }).count(); + expect(schritte).toBeGreaterThan(0); + await expect(kopfB).toContainText(`0 von ${schritte}`); + + /* + * Jetzt der eigentliche Nachweis. Gewartet wird **nur** auf den Zaehler im + * Kopf des Zuschauers — kein `reload()`, kein Klick. Kommt er dort an, hat + * der Strom funktioniert. + */ + const start = Date.now(); + await bereichA.getByRole("button", { name: "erledigt", exact: true }).first().click(); + await expect(kopfA).toContainText(`1 von ${schritte}`); + await expect(kopfB).toContainText(`1 von ${schritte}`, { timeout: 8_000 }); + const dauer = Date.now() - start; + + /* + * **Warum 5000 und nicht 1000.** Das Kriterium nennt „unter einer + * Sekunde“, und gemessen sind es hier gewoehnlich zwei- bis + * dreihundert Millisekunden. Eine Schranke von 1000 ms waere aber auf + * einer belasteten Maschine ein Test, der gelegentlich ohne Fehler im Code + * rot wird — und ein flatternder Test wird irgendwann ignoriert. 5000 ms + * beweisen das Entscheidende: **es wurde nicht gepollt**, denn das Polling + * fragt alle zehn Sekunden. + */ + expect(dauer).toBeLessThan(5_000); + console.log(`Der Haken war nach ${dauer} ms beim Zuschauer.`); + } finally { + await zweiter.close(); + } + }); + + test("ohne EventSource kommt die Änderung über das Polling an", async ({ page, browser }) => { + test.slow(); + await anmelden(page); + const pfad = await laufVorbereiten(page, `ZZ E2E Rückfall ${STEMPEL}`); + + const zweiter = await browser.newContext(); + const zuschauer = await zweiter.newPage(); + try { + /* + * `EventSource` wegnehmen, **bevor** die Seite laedt. Genau diesen Zustand + * hat ein Browser hinter einem Proxy, der Stroeme puffert: der Strom kommt + * nie an, und ohne Rueckfall sieht die Seite eine Stunde lang unveraendert + * aus. + */ + await zuschauer.addInitScript(() => { + // @ts-expect-error – genau das ist der Punkt des Ablaufs. + delete window.EventSource; + }); + await anmelden(zuschauer); + await zuschauer.goto(pfad); + expect(await zuschauer.evaluate(() => typeof EventSource)).toBe("undefined"); + + const { kopf: kopfA, bereich: bereichA } = await ersterAbschnitt(page); + const { kopf: kopfB } = await ersterAbschnitt(zuschauer); + const schritte = await bereichA.getByRole("button", { name: "erledigt", exact: true }).count(); + await expect(kopfB).toContainText(`0 von ${schritte}`); + + await bereichA.getByRole("button", { name: "erledigt", exact: true }).first().click(); + await expect(kopfA).toContainText(`1 von ${schritte}`); + + // Das Polling fragt alle zehn Sekunden – entsprechend mehr Luft. + await expect(kopfB).toContainText(`1 von ${schritte}`, { timeout: 25_000 }); + } finally { + await zweiter.close(); + } + }); +}); diff --git a/tests/lauf-ereignisse.test.ts b/tests/lauf-ereignisse.test.ts new file mode 100644 index 0000000..19e7ee5 --- /dev/null +++ b/tests/lauf-ereignisse.test.ts @@ -0,0 +1,509 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Betriebsmodus, LaufStatus, VersionStatus } from "@/generated/prisma/enums"; +import { HERZSCHLAG, KANAL, herzschlagMs, maxStroeme, sseEreignis, willStrom } from "@/lib/sse"; +import { + STROM_GESCHLOSSEN, + reaktionAufAenderung, + sollZurueckfallen, +} from "@/components/laeufe/lauf-aktualisierung"; +import { RechteFehler } from "@/server/auth/sitzungspflicht"; +import { aufLaufHoeren, ereignisseSchliessen, stroemeOffen } from "@/server/lauf-ereignisse"; +import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; + +/** + * Live-Aktualisierung per SSE statt Polling (#033). + * + * Vier Zusagen tragen das Ganze: die Datenbank meldet **jede** Aenderung, die im + * Zustand steht; der Strom prueft Recht und Mandant, bevor das erste Ereignis + * fliesst; der Herzschlag haelt die Verbindung offen; und das Polling bleibt ein + * echter Rueckfall. + * + * Die Pruefung „zwei Sitzungen sehen einander in unter einer Sekunde“ steht in + * `tests/e2e/live.spec.ts` — hier wird gezeigt, dass die Teile einzeln halten. + */ + +/* Der Handler wird echt gefahren; nur die Sitzung wird gestellt. */ +vi.mock("@/server/auth/sitzungspflicht", async (original) => { + const echt = await original(); + return { ...echt, rechtErforderlich: vi.fn() }; +}); + +const { rechtErforderlich } = await import("@/server/auth/sitzungspflicht"); +const { GET } = await import("@/app/api/laeufe/[id]/zustand/route"); + +describe("Format und Grenzen des Stroms", () => { + it("schreibt ein Ereignis im SSE-Format", () => { + expect(sseEreignis("zustand", '{"a":1}')).toBe('event: zustand\ndata: {"a":1}\n\n'); + }); + + it("gibt jeder Zeile ihr eigenes data-Feld", () => { + /* + * Die Falle des Formats: ein Zeilenumbruch beendet das Feld. Ohne diese + * Aufteilung zerfiele die Nachricht, und der Client bekäme Schrott. + */ + expect(sseEreignis("zustand", "erste\nzweite")).toBe("event: zustand\ndata: erste\ndata: zweite\n\n"); + }); + + it("erkennt den Wunsch nach einem Strom am Accept-Kopf", () => { + expect(willStrom("text/event-stream")).toBe(true); + // So schickt es der Browser wirklich. + expect(willStrom("text/event-stream, */*;q=0.1")).toBe(true); + expect(willStrom("text/event-stream;charset=utf-8")).toBe(true); + expect(willStrom("application/json")).toBe(false); + expect(willStrom("*/*")).toBe(false); + expect(willStrom(null)).toBe(false); + }); + + it("nimmt Herzschlag und Obergrenze aus der Umgebung, mit Vorgabe", () => { + expect(herzschlagMs()).toBe(25_000); + expect(maxStroeme()).toBe(200); + + process.env["SSE_HERZSCHLAG_MS"] = "50"; + process.env["SSE_MAX_STROEME"] = "3"; + expect(herzschlagMs()).toBe(50); + expect(maxStroeme()).toBe(3); + + // Unsinn fällt auf die Vorgabe zurück, statt den Strom lahmzulegen. + process.env["SSE_HERZSCHLAG_MS"] = "0"; + process.env["SSE_MAX_STROEME"] = "spaeter"; + expect(herzschlagMs()).toBe(25_000); + expect(maxStroeme()).toBe(200); + + delete process.env["SSE_HERZSCHLAG_MS"]; + delete process.env["SSE_MAX_STROEME"]; + }); + + it("fällt nur bei endgültig geschlossenem Strom auf Polling zurück", () => { + // `EventSource` verbindet sich bei CONNECTING selbst wieder – aufzugeben + // wäre verfrüht. + expect(sollZurueckfallen(0)).toBe(false); + expect(sollZurueckfallen(1)).toBe(false); + expect(sollZurueckfallen(STROM_GESCHLOSSEN)).toBe(true); + }); + + it("fragt statt neu zu laden, wenn ein Passwort auf der Seite steht", () => { + expect(reaktionAufAenderung(true)).toBe("fragen"); + expect(reaktionAufAenderung(false)).toBe("neu-laden"); + }); +}); + +async function aufbau() { + const kunde = await testPrisma.kunde.create({ + data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true }, + }); + await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } }); + const admin = await testPrisma.benutzer.create({ + data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" }, + }); + + const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } }); + const version = await testPrisma.vorlagenVersion.create({ + data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF }, + }); + const abschnitt = await testPrisma.vorlagenAbschnitt.create({ + data: { versionId: version.id, titel: "Vorbereitung", position: 0 }, + }); + const schritt = await testPrisma.vorlagenSchritt.create({ + data: { abschnittId: abschnitt.id, titel: "Rechner auspacken", position: 0, pflicht: true }, + }); + await testPrisma.vorlagenVersion.update({ + where: { id: version.id }, + data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() }, + }); + + const lauf = await testPrisma.lauf.create({ + data: { vorlagenVersionId: version.id, titel: "Ein Lauf", kundeId: kunde.id }, + }); + const ergebnis = await testPrisma.schrittergebnis.create({ + data: { laufId: lauf.id, vorlagenSchrittId: schritt.id }, + }); + + return { + kunde, + admin: { id: admin.id, rolle: admin.rolle }, + laufId: lauf.id, + schrittId: schritt.id, + ergebnisId: ergebnis.id, + }; +} + +/** Wartet, bis die Bedingung gilt – oder bricht mit einer lesbaren Meldung ab. */ +async function warteAuf(bedingung: () => boolean, was: string, grenzeMs = 5_000): Promise { + const ende = Date.now() + grenzeMs; + while (Date.now() < ende) { + if (bedingung()) return; + await new Promise((fertig) => setTimeout(fertig, 10)); + } + throw new Error(`${was} ist innerhalb von ${grenzeMs} ms nicht eingetreten.`); +} + +describe.runIf(datenbankVerfuegbar)("Lauf-Ereignisse aus der Datenbank", () => { + beforeEach(async () => { + await datenbankLeeren(); + }); + + afterEach(async () => { + await ereignisseSchliessen(); + }); + + afterAll(async () => { + await ereignisseSchliessen(); + }); + + it("meldet den Kanalnamen, den die Migration benutzt", async () => { + // Liefe der Name auseinander, hörte niemand zu – und nichts wäre rot. + const zeilen = await testPrisma.$queryRaw<{ quelle: string }[]>` + SELECT prosrc AS quelle FROM pg_proc WHERE proname = 'laufzettel_lauf_geaendert' + `; + expect(zeilen).toHaveLength(1); + expect(zeilen[0]?.quelle).toContain(`pg_notify('${KANAL}'`); + }); + + it("weckt bei einem abgehakten Schritt", async () => { + const { laufId, ergebnisId, admin } = await aufbau(); + let geweckt = 0; + const abmelden = await aufLaufHoeren(laufId, () => { + geweckt += 1; + }); + + await testPrisma.schrittergebnis.update({ + where: { id: ergebnisId }, + data: { zustand: "ERLEDIGT", aktualisiertVonId: admin.id }, + }); + + await warteAuf(() => geweckt > 0, "Die Meldung zum Schrittergebnis"); + abmelden(); + }); + + it("weckt auch bei Kommentar, Anhang und Kopfdaten", async () => { + /* + * **Der eigentliche Rückschritt-Test.** `laufZustand` trägt die Zahl der + * Kommentare und Anhänge; das Polling verglich alle zehn Sekunden den + * **ganzen** Stand und sah sie deshalb. Ein Trigger nur auf + * `Schrittergebnis` wäre gegenüber dem Polling schlechter — ein Kommentar + * des Kollegen käme nie an. + */ + const { laufId, ergebnisId, kunde, admin } = await aufbau(); + let geweckt = 0; + const abmelden = await aufLaufHoeren(laufId, () => { + geweckt += 1; + }); + + const kommentar = await testPrisma.kommentar.create({ + data: { laufId, rumpf: "Netzteil fehlte", benutzerId: admin.id }, + }); + await warteAuf(() => geweckt >= 1, "Die Meldung zum Kommentar"); + + const vorher = geweckt; + const anhang = await testPrisma.anhang.create({ + data: { + schrittergebnisId: ergebnisId, + laufId, + kundeId: kunde.id, + dateiname: "typenschild.png", + medientyp: "image/png", + groesse: 10, + pruefsumme: "b".repeat(64), + }, + }); + await warteAuf(() => geweckt > vorher, "Die Meldung zum Anhang"); + + const vorTitel = geweckt; + await testPrisma.lauf.update({ where: { id: laufId }, data: { titel: "Neuer Titel" } }); + await warteAuf(() => geweckt > vorTitel, "Die Meldung zu den Kopfdaten"); + + // Und beim Entfernen genauso – sonst bliebe ein gelöschter Anhang stehen. + const vorLoeschen = geweckt; + await testPrisma.anhang.delete({ where: { id: anhang.id } }); + await warteAuf(() => geweckt > vorLoeschen, "Die Meldung zum entfernten Anhang"); + + const vorKommentarWeg = geweckt; + await testPrisma.kommentar.delete({ where: { id: kommentar.id } }); + await warteAuf(() => geweckt > vorKommentarWeg, "Die Meldung zum entfernten Kommentar"); + + abmelden(); + }); + + it("weckt nur die Zuhörer des betroffenen Laufs", async () => { + const { laufId, ergebnisId, kunde, admin } = await aufbau(); + const version = await testPrisma.vorlagenVersion.findFirstOrThrow(); + const anderer = await testPrisma.lauf.create({ + data: { vorlagenVersionId: version.id, titel: "Ein anderer Lauf", kundeId: kunde.id }, + }); + + let hier = 0; + let dort = 0; + const ab1 = await aufLaufHoeren(laufId, () => { + hier += 1; + }); + const ab2 = await aufLaufHoeren(anderer.id, () => { + dort += 1; + }); + + await testPrisma.schrittergebnis.update({ + where: { id: ergebnisId }, + data: { zustand: "ERLEDIGT", aktualisiertVonId: admin.id }, + }); + await warteAuf(() => hier > 0, "Die Meldung am eigenen Lauf"); + + // Kurz Luft lassen: eine falsche Zustellung käme in derselben Größenordnung. + await new Promise((fertig) => setTimeout(fertig, 200)); + expect(dort).toBe(0); + + ab1(); + ab2(); + }); + + it("zählt die offenen Ströme und gibt sie beim Abmelden frei", async () => { + const { laufId } = await aufbau(); + expect(stroemeOffen()).toBe(0); + + const ab1 = await aufLaufHoeren(laufId, () => undefined); + const ab2 = await aufLaufHoeren(laufId, () => undefined); + // Zwei Techniker an einem Lauf sind zwei Verbindungen, nicht eine. + expect(stroemeOffen()).toBe(2); + + ab1(); + expect(stroemeOffen()).toBe(1); + // Zweimal abmelden darf nicht doppelt zählen. + ab1(); + expect(stroemeOffen()).toBe(1); + ab2(); + expect(stroemeOffen()).toBe(0); + }); +}); + +/** Liest aus einem Strom, bis `bis` im Gelesenen steht. */ +async function stromLesen(antwort: Response, bis: (text: string) => boolean): Promise { + const leser = antwort.body?.getReader(); + if (!leser) throw new Error("Die Antwort hat keinen Rumpf."); + const entschluessler = new TextDecoder(); + let gelesen = ""; + const ende = Date.now() + 5_000; + try { + while (Date.now() < ende) { + const { value, done } = await leser.read(); + if (value) gelesen += entschluessler.decode(value, { stream: true }); + if (bis(gelesen)) return gelesen; + if (done) return gelesen; + } + } finally { + await leser.cancel().catch(() => undefined); + } + return gelesen; +} + +describe.runIf(datenbankVerfuegbar)("Der Route Handler", () => { + beforeEach(async () => { + await datenbankLeeren(); + vi.mocked(rechtErforderlich).mockReset(); + }); + + afterEach(async () => { + await ereignisseSchliessen(); + delete process.env["SSE_HERZSCHLAG_MS"]; + delete process.env["SSE_MAX_STROEME"]; + }); + + function angemeldetAls(benutzer: { id: string; rolle: "ADMIN" }): void { + vi.mocked(rechtErforderlich).mockResolvedValue({ + benutzer: { id: benutzer.id, rolle: benutzer.rolle }, + sitzung: {}, + } as unknown as Awaited>); + } + + function anfrage(accept: string | null): Request { + return new Request("http://127.0.0.1/api/laeufe/x/zustand", { + headers: accept ? { accept } : {}, + }); + } + + it("weist ohne Anmeldung ab, bevor das erste Ereignis fließt", async () => { + const { laufId } = await aufbau(); + vi.mocked(rechtErforderlich).mockRejectedValue(new RechteFehler("Nicht angemeldet.")); + + const antwort = await GET(anfrage("text/event-stream"), { + params: Promise.resolve({ id: laufId }), + }); + + expect(antwort.status).toBe(401); + // Kein Strom: wer kein Recht hat, bekommt keine offene Leitung. + expect(antwort.headers.get("content-type")).toContain("application/json"); + expect(stroemeOffen()).toBe(0); + }); + + it("liefert für einen Lauf eines fremden Kunden keinen Strom", async () => { + const { kunde } = await aufbau(); + /* + * Die Mandantensicht greift im Modus DIENSTLEISTUNG und fuer eine Rolle + * unterhalb von REDAKTEUR – im Modus INTERN sieht jeder alles, und das ist + * so gewollt. Der Strom darf diese Grenze nicht umgehen, also wird sie hier + * hergestellt statt behauptet. + */ + await testPrisma.einstellung.update({ + where: { id: "global" }, + data: { modus: Betriebsmodus.DIENSTLEISTUNG }, + }); + const vorher = process.env["OIDC_CUSTOMER_GROUP_PREFIX"]; + process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "kunde-"; + + try { + const fremd = await testPrisma.kunde.create({ + data: { name: "Fremd GmbH", kuerzel: "FREMD" }, + }); + const version = await testPrisma.vorlagenVersion.findFirstOrThrow(); + const fremderLauf = await testPrisma.lauf.create({ + data: { vorlagenVersionId: version.id, titel: "Fremder Lauf", kundeId: fremd.id }, + }); + + // Ein Leser, der nur den eigenen Kunden sieht. + const leser = await testPrisma.benutzer.create({ + data: { email: "leser@firma.example", name: "Leser", rolle: "LESER" }, + }); + await testPrisma.benutzerKunde.create({ + data: { benutzerId: leser.id, kundeId: kunde.id }, + }); + vi.mocked(rechtErforderlich).mockResolvedValue({ + benutzer: { id: leser.id, rolle: "LESER" }, + sitzung: {}, + } as unknown as Awaited>); + + const antwort = await GET(anfrage("text/event-stream"), { + params: Promise.resolve({ id: fremderLauf.id }), + }); + + expect(antwort.status).toBe(404); + expect(stroemeOffen()).toBe(0); + } finally { + if (vorher === undefined) delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"]; + else process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = vorher; + } + }); + + it("antwortet ohne Accept-Kopf wie bisher mit JSON", async () => { + const { laufId, admin } = await aufbau(); + angemeldetAls(admin as { id: string; rolle: "ADMIN" }); + + const antwort = await GET(anfrage(null), { params: Promise.resolve({ id: laufId }) }); + + expect(antwort.status).toBe(200); + expect(antwort.headers.get("content-type")).toContain("application/json"); + expect(antwort.headers.get("cache-control")).toBe("no-store"); + const stand = (await antwort.json()) as Record; + expect(stand["status"]).toBe(LaufStatus.OFFEN); + expect(stand["anzahlPflichtOffen"]).toBe(1); + // Seit #033 im Stand, damit ein neues Foto überhaupt eine Änderung ist. + expect(stand["anzahlAnhaenge"]).toBe(0); + expect(stand["anzahlKommentare"]).toBe(0); + // Niemals Befehle: ein generiertes Passwort entsteht bei jeder Auflösung neu. + expect(JSON.stringify(stand)).not.toContain("befehl"); + }); + + it("schickt im Strom sofort den Stand und danach jede Änderung", async () => { + const { laufId, ergebnisId, admin } = await aufbau(); + angemeldetAls(admin as { id: string; rolle: "ADMIN" }); + + const antwort = await GET(anfrage("text/event-stream"), { + params: Promise.resolve({ id: laufId }), + }); + expect(antwort.status).toBe(200); + expect(antwort.headers.get("content-type")).toContain("text/event-stream"); + // Ohne diese Kopfzeile sammelt nginx den Strom und gibt ihn erst am Ende heraus. + expect(antwort.headers.get("x-accel-buffering")).toBe("no"); + + const rumpf = antwort.body; + if (!rumpf) throw new Error("Kein Rumpf."); + const leser = rumpf.getReader(); + const entschluessler = new TextDecoder(); + + async function lesenBis(bedingung: (text: string) => boolean): Promise { + let gelesen = ""; + const ende = Date.now() + 5_000; + while (Date.now() < ende) { + const { value, done } = await leser.read(); + if (value) gelesen += entschluessler.decode(value, { stream: true }); + if (bedingung(gelesen)) return gelesen; + if (done) break; + } + throw new Error(`Nichts Passendes im Strom. Gelesen: ${JSON.stringify(gelesen)}`); + } + + const erstes = await lesenBis((text) => text.includes("event: zustand")); + expect(erstes).toContain('"anzahlPflichtOffen":1'); + + // Jetzt die Änderung – sie muss von selbst durchkommen. + await testPrisma.schrittergebnis.update({ + where: { id: ergebnisId }, + data: { zustand: "ERLEDIGT", aktualisiertVonId: admin.id }, + }); + + const zweites = await lesenBis((text) => text.includes('"anzahlPflichtOffen":0')); + expect(zweites).toContain("ERLEDIGT"); + + await leser.cancel(); + }); + + it("hält die Verbindung mit einem Herzschlag offen", async () => { + // 60 s echt zu warten wäre ein Test, den niemand mehr fährt. + process.env["SSE_HERZSCHLAG_MS"] = "40"; + const { laufId, admin } = await aufbau(); + angemeldetAls(admin as { id: string; rolle: "ADMIN" }); + + const antwort = await GET(anfrage("text/event-stream"), { + params: Promise.resolve({ id: laufId }), + }); + const gelesen = await stromLesen(antwort, (text) => text.split(HERZSCHLAG).length - 1 >= 3); + + // Drei Herzschläge ohne jede Änderung am Lauf. + expect(gelesen.split(HERZSCHLAG).length - 1).toBeGreaterThanOrEqual(3); + }); + + it("fällt bei erreichter Obergrenze auf eine gewöhnliche Antwort zurück", async () => { + process.env["SSE_MAX_STROEME"] = "1"; + const { laufId, admin } = await aufbau(); + angemeldetAls(admin as { id: string; rolle: "ADMIN" }); + + const erster = await GET(anfrage("text/event-stream"), { + params: Promise.resolve({ id: laufId }), + }); + expect(erster.headers.get("content-type")).toContain("text/event-stream"); + await warteAuf(() => stroemeOffen() === 1, "Der erste Strom"); + + /* + * Der zweite bekommt JSON statt eines Stroms. `EventSource` meldet darauf + * `error` mit `readyState === CLOSED` und versucht es nicht wieder — der + * Client fällt damit von sich aus auf Polling zurück, ohne Sonderfall. + */ + const zweiter = await GET(anfrage("text/event-stream"), { + params: Promise.resolve({ id: laufId }), + }); + expect(zweiter.status).toBe(200); + expect(zweiter.headers.get("content-type")).toContain("application/json"); + expect(stroemeOffen()).toBe(1); + + await erster.body?.cancel(); + }); + + it("gibt den Strom frei, wenn der Browser abbricht", async () => { + const { laufId, admin } = await aufbau(); + angemeldetAls(admin as { id: string; rolle: "ADMIN" }); + + const steuerung = new AbortController(); + const antwort = await GET( + new Request("http://127.0.0.1/api/laeufe/x/zustand", { + headers: { accept: "text/event-stream" }, + signal: steuerung.signal, + }), + { params: Promise.resolve({ id: laufId }) }, + ); + expect(antwort.headers.get("content-type")).toContain("text/event-stream"); + await warteAuf(() => stroemeOffen() === 1, "Der Strom"); + + /* + * Ohne Freigabe wüchse das Register mit jeder geschlossenen Lasche weiter, + * und die Obergrenze zählte irgendwann Verbindungen, die es nicht gibt. + */ + steuerung.abort(); + await warteAuf(() => stroemeOffen() === 0, "Die Freigabe des Stroms"); + }); +}); diff --git a/tests/zustand-route.test.ts b/tests/zustand-route.test.ts index 730a437..fe933db 100644 --- a/tests/zustand-route.test.ts +++ b/tests/zustand-route.test.ts @@ -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 () => {