- 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
191 lines
8.1 KiB
Markdown
191 lines
8.1 KiB
Markdown
# 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.
|
||
|
||
```json
|
||
{
|
||
"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`: `count` Zeilen ab `index` werden durch `lines` ersetzt.
|
||
- `insert`: `lines` werden **vor** `index` eingefügt
|
||
(`index == Zeilenanzahl` = anhängen).
|
||
- `delete`: `count` Zeilen ab `index` entfallen.
|
||
|
||
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 (ChangeType `UPDATED`).
|
||
- **409 Conflict**: `baseVersion` ist nicht mehr aktuell. Antwort enthält
|
||
alles, was der Client zum Weiterarbeiten braucht — **ohne Neuladen**:
|
||
|
||
```json
|
||
{
|
||
"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
|
||
|
||
```json
|
||
{
|
||
"fromVersion": 41,
|
||
"currentVersion": 43,
|
||
"ops": [ ...kumuliertes Diff 41 → 43... ],
|
||
"events": [ { "version": 43, "changeType": "UPDATED" } ]
|
||
}
|
||
```
|
||
|
||
- Sonst hält der Server die Anfrage bis zu `wait` Sekunden 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`/`RESTORED` erscheinen 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; `notifyAll` bei 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
|
||
|
||
1. Diff-Modell + Anwenden/Berechnen als reine Kotlin-Funktionen (Unit-Tests)
|
||
2. `PATCH /content` inkl. 409-Antwort (Spec + Cucumber)
|
||
3. `GET /changes` mit Long Polling (Spec + Cucumber)
|
||
4. Client-Anpassung (Feed-Schleife, lokales Anwenden, Rebase)
|