Files
werkbaum/backend/docs/client-live-editing-instructions.md
T
mhoennig 0446e3d81e initial backend: Dokumente, Historie, API und Persistenz (D76)
- Liquibase-Schema (`document`, `document_history`) + Rollback-scripts
- Spring Boot with JPA-repositories, entities and services
- REST-API (`/documents`, `/documents/{id}`, `/documents/{id}/history`)
- OpenAPI-specifikation for CRUD-Operationen and history
- config files (`application.yaml`, `Liquibase`, H2 im PostgreSQL-Modus)
- preps for future live-editing/delta-updates
- Exceptions for conflikt- and not-found cases (409/404)
- keeping document hostory even after `delete` for RESTORE functionality
2026-08-26 12:56:27 +02:00

9.9 KiB

Aufgabe: Live-Editing-Client für den Werkbaum-Editor implementieren

Du arbeitest im Repository der Werkbaum-Web-App (PWA). Implementiere die Client-Seite des Live-Editing-Protokolls gegen das Editor-Backend. Das Protokoll ist HTTP-only (kein WebSocket): Änderungen werden als zeilenbasierte Diffs per PATCH eingereicht, andere Clients erhalten sie über einen Long-Polling-Feed.

Wichtiger Kontext: Die beiden Live-Editing-Endpunkte (PATCH …/content und GET …/changes) sind im Backend spezifiziert, aber ggf. noch nicht deployt. Implementiere gegen den hier definierten Vertrag und baue einen Mock-Server (oder MSW-Handler) für die Tests. Die CRUD-Endpunkte existieren bereits.


1. Backend-Vertrag

Basis-URL: konfigurierbar (VITE_BACKEND_URL o. Ä.), Pfad-Präfix /api/v1. Alle Bodies sind JSON. Fehler kommen als RFC-9457 application/problem+json.

1.1 Bestehende Endpunkte (bereits verfügbar)

  • GET /documents/{uuid}{ id, title, content, version, createdAt, updatedAt }
    • content: das komplette Werkbaum-Dokument als ein String, LF-getrennt.
    • version: Long, wird serverseitig bei jeder Änderung inkrementiert.
  • GET /documents/{uuid}/history → Historie (hier nicht benötigt).
  • POST /documents/{uuid}/restore → gelöschtes Dokument wiederherstellen.

1.2 PATCH /documents/{uuid}/content — Änderung einreichen

Request:

{
  "baseVersion": 41,
  "ops": [
    { "op": "replace", "index": 12, "count": 1, "lines": ["  - [~] Backend (L) @ben"] },
    { "op": "insert",  "index": 20, "lines": ["  + [?] Dark mode (S)"] },
    { "op": "delete",  "index": 25, "count": 2 }
  ]
}

Antworten:

Status Bedeutung Body
200 akzeptiert { "version": 42 }
409 baseVersion veraltet { "currentVersion": 43, "opsSinceBase": [ …Ops… ] }
404 Dokument gelöscht problem+json
422 Diff nicht anwendbar (Client-Bug) problem+json

Nach 200: lokale Version auf version setzen. Nach 409: siehe Rebase (§4). Nach 422: Dokument einmalig komplett neu laden (GET) und Zustand ersetzen — das ist der einzige zulässige Vollreload-Pfad.

1.3 GET /documents/{uuid}/changes?since={version}&wait=25 — Feed

  • 200: { "fromVersion": 41, "currentVersion": 43, "ops": [ … ], "events": [ { "version": 43, "changeType": "UPDATED" } ] }
    • ops ist das kumulierte Diff fromVersion → currentVersion, direkt anwendbar auf den lokalen Stand, wenn since == fromVersion.
    • changeTypeCREATED | UPDATED | DELETED | RESTORED.
  • 204: Timeout ohne Änderungen → sofort erneut pollen.
  • Netzwerkfehler/Timeout des Browsers: mit Exponential Backoff (1 s, 2 s, 4 s … max 30 s) erneut versuchen; bei Erfolg Backoff zurücksetzen.
  • Bei changeType == "DELETED": Editieren sperren, Banner „Dokument wurde gelöscht" mit Restore-Button (POST /restore) anzeigen. Bei RESTORED Sperre aufheben.

2. Diff-Format: exakte Semantik

Das Dokument ist eine Liste von Zeilen: content.split("\n"). Alle Indizes sind 0-basiert und beziehen sich auf die Basisversion (nicht auf Zwischenstände!). Ops sind nach index aufsteigend sortiert und überlappen nicht.

  • replace: ersetzt count Zeilen ab index durch lines (lines.length darf von count abweichen).
  • insert: fügt lines vor index ein; index == zeilen.length bedeutet anhängen.
  • delete: entfernt count Zeilen ab index.

Anwenden: entweder rückwärts iterieren (höchster Index zuerst), dann bleiben die Basis-Indizes gültig — oder vorwärts mit mitlaufendem Offset. Implementiere applyOps(lines: string[], ops: Op[]): string[] als pure Funktion, rückwärts iterierend (einfacher zu beweisen).

Erzeugen: implementiere computeOps(before: string[], after: string[]): Op[] mit einem Standard-Zeilen-Diff (Myers; eine kleine Bibliothek wie diff [jsdiff] mit diffArrays ist ok, dann Hunks in unsere drei Op-Typen übersetzen). Aufeinanderfolgende delete+insert am selben Index zu replace zusammenfassen.

Invarianten (als Tests absichern):

  • applyOps(before, computeOps(before, after)) ≡ after (Property-Test mit zufälligen Zeilen-Arrays, unbedingt mit Duplikaten und Leerzeilen).
  • Leeres Diff (ops: []) wird gar nicht erst gesendet.

Zeilenenden: beim Laden und vor jedem computeOps normalisieren: content.replace(/\r\n?/g, "\n"). Kein trailing-newline-Sonderfall: split("\n") auf beiden Seiten konsistent verwenden.


3. Sync-Engine (Kernmodul)

Lege ein UI-unabhängiges Modul syncEngine.ts an mit diesem Zustand:

interface SyncState {
  documentId: string;
  serverVersion: number;     // letzte bestätigte Server-Version
  serverLines: string[];     // Stand der Server-Version (Schattenkopie)
  localLines: string[];      // aktueller Editor-Inhalt
  pending: Op[] | null;      // gerade unterwegs befindlicher Patch
  status: "idle" | "sending" | "conflict" | "deleted" | "offline";
}

Abläufe:

  1. Init: GET /documents/{id}serverVersion, serverLines, localLines initialisieren; Feed-Schleife starten.
  2. Lokale Eingabe: Editor schreibt nur localLines. Ein Debounce (Empfehlung: 1500 ms nach letztem Tastendruck, zusätzlich sofort bei Blur/Fenster-Verlassen via visibilitychange) triggert flush().
  3. flush(): wenn pending leer und localLines ≠ serverLines: ops = computeOps(serverLines, localLines), PATCH senden, pending = ops, status = "sending".
    • 200 → serverVersion = antwort.version, serverLines = applyOps(serverLines, pending), pending = null. Falls sich localLines inzwischen weiter geändert hat: erneut flushen.
    • 409 → Rebase (§4).
  4. Feed-Ereignis (200): Remote-Ops einarbeiten (§5). Niemals während status == "sending" anwenden — Feed-Antworten bis zur PATCH-Antwort puffern (Queue), sonst entstehen Races zwischen eigener und fremder Änderung.

Nur eine Feed-Anfrage gleichzeitig; AbortController benutzen und beim Dokumentwechsel/Unmount abbrechen.

4. Rebase nach 409

Gegeben: eigene ungesicherte Änderung (localLines vs. serverLines) und opsSinceBase (fremd). Vorgehen:

  1. theirs = opsSinceBase, mine = computeOps(serverLines, localLines).
  2. Überlappungsprüfung: berechne für jede Op ihren betroffenen Zeilenbereich in Basis-Koordinaten ([index, index + count) bzw. für insert [index, index]). Überschneidet sich ein Bereich aus mine mit einem aus theirsechter Konflikt: status = "conflict", UI zeigt Dialog („Fremde Änderung übernehmen und meine verwerfen" / „Meine erzwingen" — letzteres = fremde Ops anwenden, eigene Zeilen darüber schreiben, als neuen Patch senden). Keine automatische Silent-Merge-Magie bei Überlappung.
  3. Kein Überlappen (Normalfall):
    • serverLines = applyOps(serverLines, theirs), serverVersion = currentVersion.
    • localLines: ebenfalls theirs anwenden, aber mit Index-Verschiebung durch die eigenen, noch nicht gesendeten Edits. Einfachste korrekte Variante: mine gegen theirs verschieben (für jede eigene Op: Summe der Zeilendelta aller fremden Ops mit kleinerem Index addieren), dann localLines = applyOps(serverLines, mineShifted).
    • Danach normal flush().

5. Remote-Ops anwenden ohne Cursor-Verlust

Beim Einarbeiten von Feed-Ops in den Editor:

  • CodeMirror 6 / Monaco: Ops in eine einzige Änderungs-Transaktion des Editors übersetzen (CM6: dispatch({changes: […]}) mit from/to-Offsets; Monaco: applyEdits). Der Editor verschiebt Cursor, Selektion und Scrollposition dann selbst korrekt. Das ist der bevorzugte Weg — niemals setValue() mit dem Gesamttext aufrufen, das ist genau der verbotene „Cursor springt an den Anfang"-Fall.
  • Rohe Textarea (Fallback): Cursor via selectionStart in (zeile, spalte) umrechnen. Pro Op: Bereich komplett unterhalb der Cursor-Zeile → nichts; komplett oberhalb → Cursor-Zeile um das Zeilendelta der Op verschieben; Op trifft die Cursor-Zeile → Zeile beibehalten (ggf. auf neue Zeilenanzahl klemmen), Spalte auf neue Zeilenlänge klemmen. Danach zurückrechnen und setSelectionRange setzen, Scrollposition vorher sichern und wiederherstellen.
  • IME: während einer aktiven Composition (compositionstart bis compositionend) keine Remote-Ops in den Editor schreiben — in einer Queue puffern und danach anwenden.

6. Was NICHT tun

  • Kein WebSocket, kein SSE, kein setInterval-Kurztakt-Polling — nur die Long-Poll-Schleife (Rate-Limit-Disziplin ist eine harte Anforderung).
  • Kein Vollreload des Dokuments außer im 422-Fall.
  • Keine Ops auf Zwischenständen aufsetzen: computeOps immer gegen serverLines der bestätigten serverVersion.
  • Keine Auto-Merges bei überlappenden Änderungen — der Nutzer entscheidet.

7. Tests (mindestens)

Unit (pure Funktionen, kein DOM):

  • applyOps: jede Op-Art; Anhängen; letzte Zeile löschen; leeres Dokument; mehrere Ops in einem Diff; Property-Test Roundtrip mit computeOps.
  • Überlappungsprüfung und Index-Verschiebung (Rebase) mit Tabellenfällen.
  • Cursor-Korrektur: Änderung oberhalb / unterhalb / auf der Cursor-Zeile; „300-Zeilen-Dokument, Edit in Zeile 5, Cursor in Zeile 200 bleibt inhaltlich an derselben Stelle".

Integration (Mock-Server/MSW):

  • Feed liefert Ops → Editorinhalt aktualisiert, Cursor stabil.
  • PATCH → 409 mit nicht überlappenden opsSinceBase → automatischer Rebase + erneuter PATCH → 200.
  • PATCH → 409 mit überlappenden Ops → Konfliktdialog erscheint.
  • Feed meldet DELETED → Editor gesperrt, Restore-Flow funktioniert.
  • Long-Poll 204 → nahtloses Re-Polling; Netzwerkfehler → Backoff.

8. Reihenfolge

  1. diff.ts: applyOps, computeOps, Bereichs-/Shift-Helfer + Unit-Tests
  2. syncEngine.ts: Zustand, flush, Feed-Schleife, Rebase + Tests gegen Mock
  3. Editor-Anbindung (Transaktions-Anwendung, Cursor, IME)
  4. UI: Konfliktdialog, Deleted-Banner, Offline-Indikator