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
This commit is contained in:
mhoennig
2026-08-26 12:56:27 +02:00
parent 4e0ce51820
commit 0446e3d81e
31 changed files with 2060 additions and 7 deletions
@@ -0,0 +1,223 @@
# 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:
```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 }
]
}
```
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`.
- `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. 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:
```ts
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 `theirs`**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.
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
+190
View File
@@ -0,0 +1,190 @@
# 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 **23 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 Konflikt­prü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)