Files
rustification-server/docs/realtime/issues/10-client-integration.md
MDietermann 55a2af4756 docs(realtime): record the design decisions behind the realtime layer
Eleven resolved decision tickets plus two research notes, produced with
/wayfinder before any code was written. They live next to the code they explain
rather than in the issue tracker: they are reasoning documents, not open work,
and in a tracker they would drift from the code with no diff and no review.

The value is in the rejected alternatives. Why a counter under a row lock and
not BIGSERIAL, why an event log and not a column on messages, why resync is a
200 with a discriminated body and not a 4xx, why the fan-out trait was deleted
again, why voice is peer-to-peer with a hard six-person cap and no speculative
SFU seam, and why Linux needs a native media stack while the other three
platforms do not.

Two entries record where the spec was wrong and got corrected: created_at was
never a safe sort key, and an unread count cannot be derived without fetching
because seq counts events rather than messages.

Open work is tracked as issues, not here.
2026-08-14 19:04:41 +02:00

122 lines
6.4 KiB
Markdown

# Client-Integration: `refreshKey` ersetzen
Type: prototype
Status: resolved
Blocked by: —
Parent: ../map.md
## Question
Wie sieht der Frontend-State aus, wenn Nachrichten gepusht statt nachgeladen werden —
und wie fühlt sich das bei N gleichzeitig verbundenen Servern an?
Ein Prototyp, kein Produktionscode. Ziel ist, an etwas Konkretem zu sehen, ob das
Zustandsmodell trägt, bevor die Spec es festschreibt.
### Ausgangslage
`ChatPage.tsx` hält heute `refreshKey` als Zähler und reicht ihn an `MessageList`
durch; `MessageInput` zählt nach dem Senden hoch. Das ist der Hack, dessen Verschwinden
den ganzen Effort motiviert.
**Zugeschnitten am 14.08.2026** durch [*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md):
die Verbindung und der Backfill liegen in Rust, das Frontend hat **keine Cursor-Logik** und
kein Debouncing. Es bekommt aufgelöste Entitäten per `emit` plus ein separates
`resync`-Signal. Das schrumpft dieses Ticket erheblich — und verschiebt eine Frage hierher:
**soll Rust für Conversations nachladen, die gar nicht sichtbar sind?** Das weiß nur das
Frontend, also muss es diese Information nach unten reichen.
### Zu klären
- **Zustandsform.** Wo leben Nachrichten — pro Conversation gecacht, oder nur die
gerade offene? Was passiert beim Wechsel zwischen Conversations? Braucht es überhaupt
eine State-Bibliothek, oder reicht React-State plus ein Cursor pro Conversation?
- **Ungelesen-Anzeige.** Der Dumb-Pipe-Event trifft auch für Conversations ein, die
nicht offen sind. Wird dann nachgeladen (kostet Requests) oder nur ein Punkt gesetzt
und erst beim Öffnen geladen? Zweiteres wirkt richtig — am Prototyp prüfen.
- **Optimistisches Senden.** Erscheint die eigene Nachricht sofort, und wie wird sie
mit der später gepushten Version zusammengeführt, ohne zu doppeln oder zu springen?
- **Multi-Server im UI.** N Server heißt N Conversation-Listen, N Verbindungszustände,
N Fehlerfälle. Wie sieht das aus, wenn ein Server nicht erreichbar ist, während die
anderen laufen? Das ist der Punkt, an dem das Multi-Server-Modell konkret wird.
- **Verbindungszustand sichtbar machen.** „Verbunden", „reconnecting", „offline" — was
sieht der Nutzer, und wo?
- **Koaleszenz.** Zehn Events in zwei Sekunden sollen einen Fetch auslösen, nicht zehn.
Wo sitzt das Debouncing, und mit welcher Verzögerung fühlt es sich noch sofort an?
### Erwartetes Ergebnis
Ein Wegwerf-Prototyp, der die Zustandsform greifbar macht, plus eine kurze Notiz, was
er über das Zustandsmodell gezeigt hat. Der Prototyp wird als Asset verlinkt, nicht in
die Antwort kopiert.
---
## Answer
Entschieden am 14.08.2026.
**Asset:** `PROTOTYPE-frontend-zustandsmodell.html` im Repo `rustification-client`,
auf dem Wegwerf-Branch `prototype/frontend-zustandsmodell` (bewusst nicht auf `master`) — eine
eigenständige HTML-Datei, im Browser zu öffnen. Freies Ausprobieren plus sechs geführte
Durchläufe. Der Reducer im ersten `<script>`-Block ist der übernehmbare Teil; die Seite
drumherum ist Wegwerfcode.
Geprüft: die Datei läuft fehlerfrei (nur ein Favicon-404). Die beiden entscheidenden
Durchläufe wurden durchgeklickt, siehe unten.
### Was der Prototyp aufgedeckt hat
**1. Der Client muss die Nachrichten-ID vergeben.** Das war vorher keine Frage — es wurde
eine, sobald optimistisches Senden konkret wurde. Vergibt der Server die ID, hat der Client
beim Echo nichts, woran er seine eigene Nachricht wiedererkennt: sie steht dann zweimal da,
einmal mit `seq` und einmal ohne. Im Prototyp beide Fälle nachgestellt und bestätigt.
Folge für die Spec: `messages.id` wird vom Client geliefert, nicht per
`DEFAULT gen_random_uuid()` erzeugt. **Nebengewinn:** damit wird das Senden idempotent —
ein Wiederholungsversuch nach unklarem Netzfehler ist ein No-op statt eines Duplikats, was
gerade auf Mobile den Unterschied macht.
⚠️ Client-gelieferte IDs sind eine Eingabe an einer Vertrauensgrenze. Der Primärschlüssel
weist Kollisionen von selbst ab, aber der Handler muss das als **Konflikt** behandeln und
nicht als Serverfehler — und die ID auf UUID-Form prüfen, statt sie ungeprüft zu übernehmen.
Auf einem öffentlichen Server ist das keine Formalie.
**2. Eine Ungelesen-*Zahl* ist ohne Nachladen nicht ableitbar.** `seq` zählt **Events**,
nicht Nachrichten — Reactions, Bearbeitungen und später Read-Receipts erhöhen ihn mit. Aus
der Differenz zwischen altem Cursor und neuem `seq` lässt sich also kein „3 neue
Nachrichten" berechnen, nur „hier hat sich etwas getan". Das war im Entwurf implizit falsch
angenommen.
**Entscheidung daraus:** Rust lädt **für DMs** nach (Zahl möglich), für Channels und Gruppen
nicht (nur ein Punkt). Das ist dasselbe Discord-Verhalten wie beim Raummodell, die Regel
kostet nichts, weil `conversations.kind` sie schon hergibt, und sie begrenzt die Last dort,
wo sie entsteht: ein aktiver Channel feuert ständig, eine DM selten. Sobald es Erwähnungen
gibt, kommen die nach derselben Logik dazu.
### Was der Prototyp bestätigt hat
- **Bearbeitete Nachrichten springen nicht.** Die Sortierung hängt an `messages.seq`, nicht
am Änderungszeitpunkt — eine Bearbeitung bewegt die Nachricht nur im Cursor-Raum, nicht im
Verlauf.
- **Löschen erzeugt einen Grabstein, keine Lücke.**
- **Der Zustand ist pro Server, nicht global.** Ein ausgefallener Server lässt die anderen
unberührt; „verdrängt" (4002) ist ein Endzustand, aus dem bewusst nicht neu verbunden wird.
- **Das Frontend rechnet nichts aus.** Es setzt gelieferte Entitäten ein und ersetzt anhand
der ID. Keine Cursor-Logik, kein Debouncing — beides liegt seit
[*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md) in Rust.
### Zustandsform
Pro Server ein Verbindungszustand; darunter je Conversation `{ geladen, ungelesen,
nachrichten[] }`. `ungelesen` ist entweder eine Zahl (DMs) oder eine Markierung (Channels) —
die Zweiwertigkeit ist keine Nachlässigkeit, sondern der Befund aus Punkt 2. Kein
Zustandsbibliothek nötig: React-State reicht, weil es keinen abgeleiteten Zustand gibt.
### Offen geblieben
Der `resync`-Pfad verwirft im Prototyp den Zwischenspeicher der betroffenen Conversation und
lädt beim nächsten Öffnen neu. Ob das für die gerade **sichtbare** Conversation sichtbar
ruckelt (kurz leer, dann voll), lässt sich am Zustandsmodell nicht beantworten — das ist eine
Frage der Oberfläche und gehört in die Umsetzung.