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.
122 lines
6.4 KiB
Markdown
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.
|