- 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
8.1 KiB
Proposal: Live-Editing über HTTP (Variante „Simpel")
Status: Entwurf zur Diskussion — noch nichts implementiert.
Ziel und Rahmenbedingungen
- Mehrere Clients (Werkbaum-Web-App/PWA) arbeiten am selben Dokument: ca. 10 Beobachter, davon 2–3 gelegentliche Editoren, praktisch nie in derselben Sekunde.
- Nur HTTP, kein WebSocket. Wenige, sparsame Requests (Lehre aus den aggressiven Rate-Limits der Etherpad-Integration).
- Kein Neuladen des Dokuments im Normalbetrieb: Clients erhalten Zeilen-Diffs und wenden sie lokal an, damit Cursor/Scrollposition erhalten bleiben.
- Das Werkbaum-Format ist zeilenorientiert; Zeilen-IDs (
#id) sind optional und identifizieren Knoten, nicht Zeilen. Das Protokoll arbeitet deshalb ausschließlich auf physischen Zeilen und braucht keine IDs. - Bei echtem Gleichzeitig-Konflikt: Update ablehnen, der Client entscheidet (rebase, neu laden, verwerfen). Das Dokument darf nie kaputtgehen.
Grundidee in einem Satz
Jede Dokumentänderung ist ein zeilenbasiertes Diff gegen eine Basisversion; der Server akzeptiert es nur, wenn die Basisversion noch die aktuelle ist (Optimistic Locking auf Dokumentebene), und verteilt akzeptierte Diffs über Long Polling an alle Beobachter.
Datenmodell: das Zeilen-Diff
Ein Diff ist eine Liste von Operationen relativ zur Basisversion. Zeilen werden über ihren Index in der Basisversion adressiert (0-basiert); Operationen sind nach Index aufsteigend sortiert und überlappen nicht.
{
"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 }
]
}
replace:countZeilen abindexwerden durchlinesersetzt.insert:lineswerden vorindexeingefügt (index == Zeilenanzahl= anhängen).delete:countZeilen abindexentfallen.
Warum Indizes statt Inhalts-Hashes reichen: Der Server kennt die
Basisversion vollständig (Historie speichert Snapshots). Version + Index ist
damit eindeutig — auch bei identischen Zeilen (Leerzeilen!). Ein optionales
"checksum"-Feld (Hash des Gesamtdokuments der Basisversion) dient nur als
Integritätsprüfung gegen Client-Bugs.
Werkbaum-Besonderheiten sind damit automatisch abgedeckt: Fortsetzungszeilen
( \), "-Beschreibungszeilen und der ----Beschreibungsteil sind schlicht
physische Zeilen. Halbfertige Zwischenzustände rendert Werkbaum mit Warnung
weiter — zeilenweise Updates sind hier risikoarm.
API-Erweiterung (OpenAPI-Spec)
1. PATCH /documents/{id}/content — Änderung einreichen
Request: das Diff-Objekt oben.
-
200 OK: akzeptiert. Antwort:
{ "version": 42 }(neue Version). Der Server wendet das Diff an, inkrementiert die Dokumentversion, schreibt einen Historieneintrag (ChangeTypeUPDATED). -
409 Conflict:
baseVersionist nicht mehr aktuell. Antwort enthält alles, was der Client zum Weiterarbeiten braucht — ohne Neuladen:{ "currentVersion": 43, "opsSinceBase": [ ...Diff von baseVersion → currentVersion... ] }Der Client entscheidet:
- Rebase: fremde Ops lokal anwenden; überlappen sie nicht mit den eigenen Änderungen, eigene Ops auf neue Indizes verschieben und erneut senden. Deckt den häufigsten Fall („jemand hat weiter oben editiert") ohne Nutzerinteraktion ab.
- Konflikt anzeigen: bei Überlappung Nutzer fragen (übernehmen / verwerfen / manuell mergen).
-
404: Dokument gelöscht (Restore-Hinweis in der Problem-Detail-Antwort).
-
422: Diff nicht anwendbar (Index außerhalb, Checksum-Fehler) — deutet auf einen Client-Bug, Client sollte neu laden.
PUT /documents/{id} bleibt als „Ganzdokument ersetzen" bestehen
(Import, Reparatur), wertet aber künftig expectedVersion aus.
2. GET /documents/{id}/changes?since={version}&wait={seconds} — Änderungsfeed
Long Polling, der Kern der „Echtzeit ohne WebSocket"-Lösung:
-
Gibt es bereits Änderungen nach
since: sofort 200 mit{ "fromVersion": 41, "currentVersion": 43, "ops": [ ...kumuliertes Diff 41 → 43... ], "events": [ { "version": 43, "changeType": "UPDATED" } ] } -
Sonst hält der Server die Anfrage bis zu
waitSekunden offen (Empfehlung: 25 s, unterhalb üblicher Proxy-Timeouts). Kommt in der Zeit eine Änderung, antwortet er sofort; sonst 204 No Content, und der Client pollt erneut. -
Latenz: praktisch sofort. Kosten: 1 offene HTTP-Anfrage pro Beobachter, ~2,4 Requests/Minute im Leerlauf — rate-limit-freundlich, PWA-tauglich, kein WebSocket nötig.
-
DELETED/RESTOREDerscheinen als Events im Feed, damit Beobachter auch Löschung/Wiederherstellung live mitbekommen.
Client-Schleife eines Beobachters:
loop:
antwort = GET /changes?since=meineVersion&wait=25
wenn 200: ops lokal anwenden, meineVersion = currentVersion,
Cursor-Indizes um Verschiebungen oberhalb korrigieren
wenn 204: weiter
Warum Long Polling und nicht SSE?
Server-Sent Events wären die Alternative (eine dauerhafte Verbindung, Push vom Server). Long Polling gewinnt hier, weil es (a) reines Request/Response-HTTP ist — trivial mit unseren Cucumber-Tests testbar, (b) keinerlei Sonderbehandlung in Proxies/PWA-Service-Workern braucht und (c) bei 10 Beobachtern der Effizienzunterschied irrelevant ist. Ein späterer Umstieg auf SSE oder WebSocket ändert nur den Feed-Endpunkt; Diff-Format und Konfliktlogik bleiben identisch.
Serverseitige Umsetzung
- Diff anwenden: Snapshot der Basisversion aus der Historie laden
(bzw. aktueller Stand, wenn
baseVersion == currentVersion, der Normalfall), Ops anwenden, als neue Version speichern. - Diff berechnen (für 409-Antwort und Feed): Zeilen-Diff zwischen zwei
Snapshots aus der Historie (Standard-Algorithmus, z. B. Myers über
java.util-nahe Bibliothek oder eigene simple Implementierung). Alternativ können eingereichte Ops pro Version direkt mitgespeichert werden — Optimierung, kein Muss für v1. - Long Polling: Spring MVC
DeferredResult+ ein In-Process-Notifier (pro Dokument eine Warteliste;notifyAllbei akzeptiertem Update). Kein zusätzliches Framework nötig. - Serialisierung: Updates pro Dokument strikt sequenziell (Locking pro Dokument-UUID), damit Versionsprüfung + Anwenden atomar sind.
- Historie: unverändert Snapshots; das Keyframe/Kompressions-Schema aus
der Speicher-Evaluation ist eine spätere, unabhängige Optimierung hinter
dem
DocumentHistoryRepository-Interface.
Grenzen der simplen Variante (bewusst akzeptiert)
- Konflikterkennung auf Dokumentebene: Zwei Editoren, die gleichzeitig verschiedene Stellen ändern, erzeugen formal einen Konflikt — der Rebase-Mechanismus in der 409-Antwort löst das aber in der Praxis transparent. Erst wenn das nicht reicht, lohnt Konfliktprüfung pro Zeilenbereich (die 409-Struktur bleibt dabei gleich).
- Kein Präsenz-Feature (wer ist online, fremde Cursor). Später über ein
leichtgewichtiges
presence-Feld im Feed nachrüstbar; das!!!-Fokusmark des Formats kann dafür genutzt werden. - Clientseitige Verschlüsselung: Das Protokoll transportiert Zeilen als opake Strings und funktioniert unverändert mit Ciphertext pro Zeile — nur das serverseitige Diff-Berechnen entfiele dann (Clients müssten Ops immer selbst liefern; die Struktur erlaubt das bereits).
Teststrategie
- Cucumber: „Client B sieht die Änderung von Client A im Feed", „Patch mit veralteter Basisversion liefert 409 mit opsSinceBase", „Feed meldet DELETED", „Rebase-Fall: nicht überlappende Änderung nach 409 erneut einreichen".
- Unit-Tests: Diff-Anwendung (alle drei Ops, Randfälle: leeres Dokument, Anhängen, letzte Zeile), Diff-Berechnung, Index-Verschiebung.
Vorschlag Umsetzungsreihenfolge
- Diff-Modell + Anwenden/Berechnen als reine Kotlin-Funktionen (Unit-Tests)
PATCH /contentinkl. 409-Antwort (Spec + Cucumber)GET /changesmit Long Polling (Spec + Cucumber)- Client-Anpassung (Feed-Schleife, lokales Anwenden, Rebase)