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.
6.4 KiB
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:
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 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.