- 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
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" } ] }opsist das kumulierte DifffromVersion → currentVersion, direkt anwendbar auf den lokalen Stand, wennsince == fromVersion.changeType∈CREATED | 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. BeiRESTOREDSperre 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: ersetztcountZeilen abindexdurchlines(lines.lengthdarf voncountabweichen).insert: fügtlinesvorindexein;index == zeilen.lengthbedeutet anhängen.delete: entferntcountZeilen abindex.
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:
- Init:
GET /documents/{id}→serverVersion,serverLines,localLinesinitialisieren; Feed-Schleife starten. - Lokale Eingabe: Editor schreibt nur
localLines. Ein Debounce (Empfehlung: 1500 ms nach letztem Tastendruck, zusätzlich sofort bei Blur/Fenster-Verlassen viavisibilitychange) triggertflush(). flush(): wennpendingleer undlocalLines ≠ serverLines:ops = computeOps(serverLines, localLines), PATCH senden,pending = ops,status = "sending".- 200 →
serverVersion = antwort.version,serverLines = applyOps(serverLines, pending),pending = null. Falls sichlocalLinesinzwischen weiter geändert hat: erneut flushen. - 409 → Rebase (§4).
- 200 →
- 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:
theirs = opsSinceBase,mine = computeOps(serverLines, localLines).- Ü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 ausminemit einem austheirs→ echter 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. - Kein Überlappen (Normalfall):
serverLines = applyOps(serverLines, theirs),serverVersion = currentVersion.localLines: ebenfallstheirsanwenden, aber mit Index-Verschiebung durch die eigenen, noch nicht gesendeten Edits. Einfachste korrekte Variante:minegegentheirsverschieben (für jede eigene Op: Summe der Zeilendelta aller fremden Ops mit kleinerem Index addieren), dannlocalLines = 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 — niemalssetValue()mit dem Gesamttext aufrufen, das ist genau der verbotene „Cursor springt an den Anfang"-Fall. - Rohe Textarea (Fallback): Cursor via
selectionStartin(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 undsetSelectionRangesetzen, Scrollposition vorher sichern und wiederherstellen. - IME: während einer aktiven Composition (
compositionstartbiscompositionend) 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:
computeOpsimmer gegenserverLinesder bestätigtenserverVersion. - 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 mitcomputeOps.- Ü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
diff.ts:applyOps,computeOps, Bereichs-/Shift-Helfer + Unit-TestssyncEngine.ts: Zustand, flush, Feed-Schleife, Rebase + Tests gegen Mock- Editor-Anbindung (Transaktions-Anwendung, Cursor, IME)
- UI: Konfliktdialog, Deleted-Banner, Offline-Indikator