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:
@@ -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
|
||||
Reference in New Issue
Block a user