docs: Live-Editing-Konzept entschieden und eingearbeitet (D76)

Die offenen Punkte der beiden Entwuerfe sind beantwortet und in beide
Dokumente eingearbeitet. Die wichtigsten Aenderungen am Protokoll:

- Der Server rebased selbst; 409 nur noch bei echter Ueberlappung. Reines
  Ablehnen fuehrt zu Starvation - ein Client mit hoeherer Latenz kommt bei
  fleissigen Mitschreibern womoeglich nie durch.
- Der Feed arbeitet auf der Historie statt am Dokument, sonst koennte er
  ausgerechnet das Loeschen nicht melden, das er melden soll.
- Historie in zwei Ebenen: kurzlebige Sync-Versionen und nutzersichtbare
  Meilensteine (nach Schreibpause und auf Knopfdruck, wie D54).
- Pruefsumme als Pflichtfeld, clientId + seq gegen doppelt angewendete
  Patches, Volltext im Feed fuer zu alte Staende, eigener Aenderungstyp fuer
  den Rollback, Titel im Feed-Ereignis.
- Zugriff ueber die unerratbare UUID; GET /documents bekommt ein
  Master-Passwort (Spring Security).

Die Client-Instruktion war fuer ein anderes Frontend geschrieben: Sie nannte
CodeMirror, TypeScript, jsdiff und MSW. Werkbaum hat eine rohe textarea,
Vanilla JS und keine Laufzeit-Abhaengigkeiten - CodeMirror bleibt eine eigene
Entscheidung (gemessen: 120 kB gzip Einstieg, danach nur 18 kB fuer alles
Weitere).

Zielumgebung vermessen: Apache 2.4.68 mit MPM event, 1024 Worker,
Timeout 300 - Long Polling traegt dort. Offen bleiben fehlendes HTTP/2,
nur Java 17 statt 21 und der ungeklaerte Weg vom Apache zum Backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-26 15:04:00 +02:00
co-authored by Claude Opus 5
parent 40db41531e
commit 2540bbf0a7
3 changed files with 702 additions and 199 deletions
+239 -123
View File
@@ -1,33 +1,60 @@
# 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.
Du arbeitest im **Werkbaum-Monorepo**, Teilprojekt `frontend/`. Implementiere
die Client-Seite des Live-Editing-Protokolls gegen das Editor-Backend
(`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.
**Wichtiger Kontext:** Die Live-Editing-Endpunkte sind spezifiziert
(`backend/docs/live-editing-proposal.md`, Entscheidungen in
`docs/DECISIONS.md` D76), aber noch nicht implementiert. Baue gegen den hier
definierten Vertrag; die CRUD-Endpunkte existieren bereits.
## 0. Randbedingungen des Projekts — bitte zuerst lesen
Diese Punkte sind nicht verhandelbar und bestimmen jede
Implementierungsentscheidung unten:
- **Keine Laufzeit-Abhängigkeiten.** `frontend/package.json` hat ein leeres
`dependencies`; alles unter `devDependencies` (Vite, Vitest, jsdom)
verschwindet beim Bauen. D11/D19/D20 und CLAUDE.md: neue
Laufzeit-Abhängigkeiten nur nach Rückfrage. Also **kein jsdiff**, **kein
CodeMirror**, **kein MSW** — Diff und Test-Doubles werden selbst gebaut.
- **Vanilla JavaScript**, keine TypeScript-Dateien. Module liegen als
`frontend/src/*.js` (siehe `parser.js`, `model.js`, `render.js`,
`snapshots.js`, `autocomplete.js`).
- **Der Editor ist eine rohe `<textarea wrap="off">`** (`index.html`, D49).
Ein Editor-Framework ist bewusst nicht im Spiel (D76: CodeMirror wäre eine
eigene Entscheidung mit eigenem Nutzen, nicht die Nebenwirkung dieses
Features). Cursor-Erhalt beim Einspielen fremder Änderungen ist deshalb
**deine Aufgabe**, nicht die eines Frameworks.
- **Testbare Logik gehört in ein eigenes Modul.** Die Hausregel aus
D54-Nachtrag 3: Was entscheidbar ist, wandert headless nach `src/*.js` und
bekommt Vitest-Tests; in `app.js` bleibt nur die Verdrahtung mit DOM und
Netz.
- **Deutsch ist die Quellsprache** für neue UI-Texte (`I18N` in
`frontend/index.html`), danach in alle neun Sprachen übersetzen.
---
## 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`.
Basis-URL: konfigurierbar, Pfad-Präfix `/api/v1`. Alle Bodies sind JSON.
Fehler kommen als RFC-9457 `application/problem+json`.
### 1.1 Bestehende Endpunkte (bereits verfügbar)
### 1.1 Bestehende Endpunkte
- `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).
- `GET /documents/{uuid}/history`Meilensteine (siehe §7).
- `POST /documents/{uuid}/restore` → gelöschtes Dokument wiederherstellen.
`GET /documents` (Liste) verlangt ein Master-Passwort und ist für den Editor
**nicht** vorgesehen — er kennt seine Dokumente über ihre URL (§6).
### 1.2 `PATCH /documents/{uuid}/content` — Änderung einreichen
Request:
@@ -35,6 +62,9 @@ Request:
```json
{
"baseVersion": 41,
"checksum": "sha256:9f2b…",
"clientId": "c-8a41…",
"seq": 17,
"ops": [
{ "op": "replace", "index": 12, "count": 1, "lines": [" - [~] Backend (L) @ben"] },
{ "op": "insert", "index": 20, "lines": [" + [?] Dark mode (S)"] },
@@ -43,31 +73,61 @@ Request:
}
```
- `checksum` ist **Pflicht**: Hash des vollständigen Basistexts. Er fängt
Client-Bugs und abweichende Zeilenenden, bevor Ops an falschen Indizes
landen.
- `clientId` ist eine zufällige, im `localStorage` gehaltene Kennung; `seq`
eine je Client aufsteigende Nummer. Beides zusammen macht den Request
**wiederholbar** (§3.5).
Antworten:
| Status | Bedeutung | Body |
|---|---|---|
| 200 | akzeptiert | `{ "version": 42 }` |
| 409 | `baseVersion` veraltet | `{ "currentVersion": 43, "opsSinceBase": [ …Ops… ] }` |
| 200 | akzeptiert | `{ "version": 42, "opsSinceBase": [ … ] }` |
| 409 | echte Überlappung | `{ "currentVersion": 43, "opsSinceBase": [ … ] }` |
| 404 | Dokument gelöscht | problem+json |
| 422 | Diff nicht anwendbar (Client-Bug) | problem+json |
| 422 | Diff nicht anwendbar oder Prüfsumme falsch | 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.
**Der Server rebased selbst.** War `baseVersion` veraltet, überschneiden sich
die Ops aber nicht mit den zwischenzeitlichen, verschiebt der Server sie und
antwortet mit **200**; `opsSinceBase` enthält dann die fremden Ops, damit du
deine Schattenkopie nachziehst. Ein **409 kommt nur bei echter Überlappung**
derselben Zeilen — nur dafür brauchst du den Konfliktdialog (§4).
Nach 200: `serverVersion = version`, dann erst `opsSinceBase`, dann die
eigenen Ops auf die Schattenkopie anwenden. Nach 422: Dokument einmalig
komplett neu laden (GET) und Zustand ersetzen — das ist neben dem
Volltext-Feed (§1.3) der einzige Vollreload-Pfad.
**Titel:** `PATCH /documents/{uuid}/title` mit `{ title, expectedVersion }`.
Der Titel ist ein Metadatum und läuft nicht durch das Zeilen-Diff.
### 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 `fromVersioncurrentVersion`,
direkt anwendbar auf den lokalen Stand, wenn `since == fromVersion`.
- `changeType``CREATED | UPDATED | DELETED | RESTORED`.
- 204: Timeout ohne Änderungen → sofort erneut pollen.
- **200 mit `ops`**:
`{ "fromVersion": 41, "currentVersion": 43, "ops": [ … ], "events": [ … ] }`
- `ops` ist das **kumulierte** Diff `fromVersion → currentVersion`.
- **200 mit `content`**:
`{ "fromVersion": null, "currentVersion": 87, "content": "…", "events": [ … ] }`
- Kommt, wenn `since` bereits verdichtet ist (langes Offline, alter Tab).
Ersetze den Stand vollständig. Kein Fehler, kein Sonderzustand.
- **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.
`events` trägt je Eintrag `{ version, changeType, clientId, displayName }`,
`changeType``UPDATED | DELETED | RESTORED | ROLLED_BACK | RENAMED`:
- **`DELETED`**: Editieren sperren, Banner „Dokument wurde gelöscht" mit
Restore-Knopf (`POST /restore`).
- **`RESTORED`**: Ein gelöschtes Dokument ist wieder da — Sperre aufheben.
- **`ROLLED_BACK`**: Ein *lebendes* Dokument wurde auf eine alte Version
zurückgesetzt. Es gab keine Sperre; behandle es wie ein gewöhnliches
Inhaltsupdate. (Die beiden Fälle trugen früher denselben Typ — deshalb die
Trennung.)
- **`RENAMED`**: Das Ereignis führt den neuen Titel mit; übernimm ihn in den
Dokumentnamen.
---
@@ -84,140 +144,196 @@ Alle Indizes sind **0-basiert und beziehen sich auf die Basisversion**
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).
**Anwenden:** `applyOps(lines, ops)` als pure Funktion, **rückwärts
iterierend** (höchster Index zuerst) — dann bleiben die Basis-Indizes gültig
und die Korrektheit ist leicht zu sehen.
**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.
**Erzeugen:** `computeOps(before, after)` **selbst implementieren** — ein
zeilenbasiertes Diff über die längste gemeinsame Teilfolge, rund hundert
Zeilen (dieselbe Größenordnung wie der Zeilenumbruch aus D64). Zeilen für den
Vergleich als Ganzes behandeln; aufeinanderfolgende delete+insert am selben
Index zu `replace` zusammenfassen. **Keine Bibliothek** (§0).
**Überlappung** (für den Konfliktfall, §4): Der betroffene Bereich ist
`[index, index+count)` für `replace` und `delete`, für `insert` ein **Punkt**
bei `index`. Daraus folgt — und das ist Absicht:
- Zwei Einfügungen an derselben Stelle sind **kein** Konflikt; beide Zeilen
bleiben, die bereits bestätigte fremde steht oben.
- Eine Einfügung in einen Bereich, den ein anderer **löscht**, ist einer.
**Invarianten (als Tests absichern):**
- `applyOps(before, computeOps(before, after)) ≡ after` (Property-Test mit
zufälligen Zeilen-Arrays, unbedingt mit Duplikaten und Leerzeilen).
- `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.
`content.replace(/\r\n?/g, "\n")`. Der Server normalisiert beim Speichern
ebenfalls auf LF — nur so hashen beide Seiten denselben Text (Prüfsumme!).
Kein trailing-newline-Sonderfall: `split("\n")` auf beiden Seiten konsistent.
---
## 3. Sync-Engine (Kernmodul)
Lege ein UI-unabhängiges Modul `syncEngine.ts` an mit diesem Zustand:
Lege ein UI-unabhängiges Modul `frontend/src/sync.js` 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";
}
```js
// {
// documentId, // UUID
// serverVersion, // letzte bestätigte Server-Version
// serverLines, // Stand der Server-Version (Schattenkopie)
// localLines, // aktueller Editor-Inhalt
// pending, // gerade unterwegs befindlicher Patch (oder null)
// seq, // laufende Nummer des nächsten Patches
// status, // 'idle' | 'sending' | 'conflict' | 'deleted' | 'offline'
// }
```
Abläufe:
Die reinen Funktionen (Diff, Überlappung, Verschiebung, Cursor-Korrektur)
gehören nach `frontend/src/diff.js` und bekommen Vitest-Tests; `sync.js`
hält den Ablauf, `app.js` nur die Verdrahtung.
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.
2. **Lokale Eingabe:** Der Editor schreibt nur `localLines`. Ein Debounce von
**1500 ms** nach dem letzten Tastendruck, zusätzlich sofort bei Blur und
`visibilitychange`, triggert `flush()`. Der Wert bleibt bewusst kurz: Die
Rate-Limit-Disziplin galt einem fremden Etherpad-Server, am eigenen
Backend gelten eigene Grenzen (D76).
3. **`flush()`:** wenn kein `pending` und `localLines ≠ serverLines`:
`ops = computeOps(serverLines, localLines)`, Prüfsumme über
`serverLines.join("\n")` bilden, PATCH senden, `pending = ops`,
`status = 'sending'`.
- **200** → `serverVersion = antwort.version`; erst
`serverLines = applyOps(serverLines, antwort.opsSinceBase)` (falls
vorhanden), dann `applyOps(…, pending)`; `pending = null`, `seq++`.
Hat sich `localLines` inzwischen weiter geändert: erneut flushen.
- **409** → Konflikt (§4).
- **422** → einmalig neu laden.
4. **Feed-Ereignis:** Remote-Ops einarbeiten (§5). Während
`status === 'sending'` **nicht** anwenden, sondern puffern — sonst entsteht
ein Race zwischen eigener und fremder Änderung.
5. **Die gepufferte Antwort nur anwenden, wenn ihr `fromVersion` zur
aktuellen `serverVersion` passt**, sonst verwerfen. Ohne diese Prüfung
wendest du dieselben Ops doppelt an: Feed und die Antwort auf deinen PATCH
können dasselbe fremde Diff enthalten.
6. **Wiederholung nach Netzwerkfehler:** Denselben Patch mit **derselben
`seq`** erneut senden. Der Server erkennt ihn und wendet ihn nicht zweimal
an. `seq` erst nach einer bestätigten Antwort erhöhen — genau darin liegt
der Schutz.
Nur **eine** Feed-Anfrage gleichzeitig; `AbortController` benutzen und beim
Dokumentwechsel/Unmount abbrechen.
Dokumentwechsel/Unmount abbrechen. `status = 'offline'` gilt, sobald ein
Request am Netz scheitert, und endet mit der nächsten erfolgreichen Antwort;
lokale Änderungen bleiben dabei erhalten und werden danach normal geflusht.
## 4. Rebase nach 409
## 4. Konflikt nach 409
Gegeben: eigene ungesicherte Änderung (`localLines` vs. `serverLines`) und
`opsSinceBase` (fremd). Vorgehen:
Ein 409 heißt: Eure Änderungen betreffen **dieselben Zeilen**. Alles andere
hat der Server bereits rebased.
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()`.
1. `serverLines = applyOps(serverLines, opsSinceBase)`,
`serverVersion = currentVersion`.
2. `status = 'conflict'`, Editieren weiter erlauben, aber nicht senden.
3. Dialog mit genau zwei Möglichkeiten, jeweils **nur für die überlappenden
Zeilen** — alles Übrige ist bereits sauber übernommen:
- **Fremde Fassung übernehmen**: die eigenen überlappenden Ops verwerfen.
- **Eigene Fassung durchsetzen**: die überlappenden Zeilen des neuen
Serverstands durch die eigenen ersetzen und als neuen Patch senden.
Dort gewinnt einer vollständig — das ist bewusst so, und nichts geht
endgültig verloren: Jede Version steht in der Server-Historie.
4. Keine automatische Zusammenführung bei Überlappung, keine Konfliktmarker
im Text.
## 5. Remote-Ops anwenden ohne Cursor-Verlust
Beim Einarbeiten von Feed-Ops in den Editor:
Der Editor ist eine `<textarea>` — die Umrechnung ist deine Aufgabe, und sie
gehört als **pure Funktion** nach `diff.js`:
- **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.
- Cursor (`selectionStart`/`selectionEnd`) in `(zeile, spalte)` umrechnen.
- Pro Op: Bereich vollständig **unterhalb** der Cursor-Zeile → nichts;
vollständig **oberhalb** → Cursor-Zeile um das Zeilendelta der Op
verschieben; Op **trifft** die Cursor-Zeile → Zeile beibehalten (auf die
neue Zeilenanzahl klemmen), Spalte auf die neue Zeilenlänge klemmen.
- Zurückrechnen, `setSelectionRange` setzen; Scrollposition **vorher sichern
und wiederherstellen** (auch `scrollLeft` — das Textfeld bricht nicht um,
D49).
- Der Zeilennummern-Streifen und der Spiegel (D33) müssen danach neu
gemessen werden, ebenso das Diagramm.
- **IME:** Während einer aktiven Composition (`compositionstart` bis
`compositionend`) keine Remote-Ops schreiben — puffern und danach anwenden.
- **Niemals** den Gesamttext neu setzen. Das ist genau der verbotene
„Cursor springt an den Anfang"-Fall.
## 6. Was NICHT tun
**Faltmarken sind gewöhnlicher Text.** Klappt jemand im Diagramm um, schreibt
Werkbaum `>`/`<` in den Text zurück (D38-Nachtrag 2) — das ist eine ganz
normale lokale Änderung und geht als PATCH hinaus wie jede andere. Das ist
entschieden (D76) und braucht keine Sonderbehandlung, auch nicht für den
Falt-Durchschalter (D75).
## 6. Einbettung in den Editor
- **Adressierung über einen URL-Parameter**, im Muster von `?sourceUrl=`
(D23) und `?etherpad=` (D31): Die Dokument-Identität leitet sich aus der
URL ab, derselbe Link führt immer in dasselbe Dokument, der Name ist die
URL (vollständig im Tooltip). Ein Menüeintrag **„Auf den Server legen"**
legt ein lokales Dokument neu an — analog zu „Als Datei speichern" (D72).
- **Der Verlaufs-Knopf zeigt bei Server-Dokumenten die Server-Meilensteine**
statt der lokalen Stände (D54): gleiche Bedienung, bessere Quelle. Lokale
Stände wären hier irreführend, weil „mein Stand von vorhin" fremde
Änderungen enthält, die man nie gesehen hat.
- Textfeld **nicht** schreibgeschützt (anders als bei Pad-Dokumenten, D31) —
hier ist unser Textfeld die Schreibfläche.
## 7. 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`.
Long-Poll-Schleife.
- Keine neue Laufzeit-Abhängigkeit (§0) — insbesondere kein jsdiff und kein
Editor-Framework.
- Kein Vollreload außer bei 422 und beim Volltext-Feed.
- Keine Ops auf Zwischenständen: `computeOps` immer gegen `serverLines` der
bestätigten `serverVersion`.
- Keine Auto-Merges bei überlappenden Änderungen — der Nutzer entscheidet.
- `seq` nicht erhöhen, bevor eine Antwort da ist.
## 7. Tests (mindestens)
## 8. Tests (mindestens)
Unit (pure Funktionen, kein DOM):
Unit (pure Funktionen, kein DOM, Vitest wie die übrigen Module):
- `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.
- Überlappungsprüfung: `insert` gegen `insert` am selben Index (kein
Konflikt), `insert` in einen `delete`-Bereich (Konflikt), Ränder.
- Index-Verschiebung 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".
- Prüfsummenbildung inklusive Zeilenenden-Normalisierung.
Integration (Mock-Server/MSW):
Integration (mit selbstgebauten `fetch`-Doubles, **kein 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.
- PATCH mit veralteter Basis ohne Überlappung → 200 mit `opsSinceBase`
Schattenkopie stimmt danach mit dem Server überein.
- PATCH → 409 mit überlappenden Ops → Konfliktdialog erscheint, beide Wege
führen zu einem konsistenten Zustand.
- Derselbe Patch zweimal gesendet (simulierter Antwortverlust) → Dokument
ändert sich nur einmal.
- Feed liefert `content` statt `ops` → Stand wird ersetzt.
- Feed meldet `DELETED` → gesperrt; `RESTORED` → entsperrt; `ROLLED_BACK`
Inhalt gewechselt, keine Sperre; `RENAMED` → Name übernommen.
- Gepufferte Feed-Antwort mit veraltetem `fromVersion` → verworfen, keine
doppelte Anwendung.
- Long-Poll 204 → nahtloses Re-Polling; Netzwerkfehler → Backoff.
## 8. Reihenfolge
## 9. 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
1. `frontend/src/diff.js`: `applyOps`, `computeOps`, Überlappungs- und
Verschiebungs-Helfer, Cursor-Korrektur + Unit-Tests
2. `frontend/src/sync.js`: Zustand, `flush`, Feed-Schleife, Idempotenz +
Tests gegen `fetch`-Doubles
3. Editor-Anbindung (Anwenden ohne Cursor-Verlust, Zeilennummern und
Diagramm nachziehen, IME)
4. Einbettung: URL-Parameter, „Auf den Server legen", Verlauf vom Server
5. UI: Konfliktdialog, Deleted-Banner, Offline-Anzeige — Texte zuerst auf
Deutsch, dann in alle neun Sprachen
+270 -76
View File
@@ -1,6 +1,9 @@
# Proposal: Live-Editing über HTTP (Variante „Simpel")
# Live-Editing über HTTP (Variante „Simpel")
Status: Entwurf zur Diskussion — noch nichts implementiert.
Status: **Konzept entschieden** (D76), noch nichts implementiert. Die offenen
Punkte des ersten Entwurfs sind beantwortet; die Begründungen stehen in
`docs/DECISIONS.md` unter D76 und werden hier nicht wiederholt, sondern nur
verwiesen.
## Ziel und Rahmenbedingungen
@@ -8,10 +11,10 @@ Status: Entwurf zur Diskussion — noch nichts implementiert.
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).
aggressiven Rate-Limits der Etherpad-Integration, D31).
- **Kein Neuladen des Dokuments** im Normalbetrieb: Clients erhalten
Zeilen-Diffs und wenden sie lokal an, damit Cursor/Scrollposition erhalten
bleiben.
bleiben. Ausnahmen sind benannt (Prüfsummenfehler, zu alter Stand).
- 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.
@@ -19,12 +22,28 @@ Status: Entwurf zur Diskussion — noch nichts implementiert.
entscheidet** (rebase, neu laden, verwerfen). Das Dokument darf nie
kaputtgehen.
### Zugriff und Identität
- **Zugriff über die unerratbare Dokument-UUID**, wie ein Pad-Link: kein
Login, kein Rechtemodell. Echte Authentifizierung kommt später als Schicht
davor; das Protokoll bleibt davon unberührt.
- **`GET /documents` verlangt ein Master-Passwort** (Hash serverseitig in
einer Umgebungsvariable, geprüft über Spring Security). Ohne diesen Schutz
wäre jede UUID auflistbar und das Modell hinfällig. Der Endpunkt braucht
eine **Sperre nach Fehlversuchen**, und die Übertragung setzt HTTPS voraus.
- **Identität ist pseudonym**: Jeder Client führt eine zufällige `clientId`
und einen selbstgewählten Anzeigenamen (Etherpad-Modell). Ohne Anmeldung
ist der Name eine Behauptung und darf in der Oberfläche nicht wie ein
Nachweis aussehen. Er trägt: Wiedererkennung beim Retry, „geändert von" in
der Historie, die Reihenfolge bei gleichzeitigen Einfügungen und spätere
Präsenz.
## 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.
Basisversion**; der Server wendet es an — notfalls auf eine neuere Version
verschoben — und verteilt das Ergebnis über **Long Polling** an alle
Beobachter.
## Datenmodell: das Zeilen-Diff
@@ -35,6 +54,9 @@ Operationen sind nach Index aufsteigend sortiert und überlappen nicht.
```json
{
"baseVersion": 41,
"checksum": "sha256:9f2b…",
"clientId": "c-8a41…",
"seq": 17,
"ops": [
{ "op": "replace", "index": 12, "count": 1,
"lines": [" - [~] Backend (L) @ben"] },
@@ -51,15 +73,38 @@ Operationen sind nach Index aufsteigend sortiert und überlappen nicht.
- `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.
Basisversion vollständig. Version + Index ist damit eindeutig — auch bei
identischen Zeilen (Leerzeilen!).
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.
**`checksum` ist Pflicht** (Hash des gesamten Basistexts). Die Versionsnummer
bestätigt nur, dass die Basis dieselbe *Version* ist, nicht dass beide Seiten
sie *gleich lesen*; ein Index-Versatz zerstörte Text sonst unbemerkt. Passt
die Prüfsumme nicht, antwortet der Server mit **422**, und der Client lädt
einmalig komplett neu. Siehe D76, Begründung im Geist von D59 („lieber der
laute Fehler").
**`clientId` und `seq` machen den PATCH wiederholbar.** Geht die Antwort
unterwegs verloren — im Mobilnetz der Normalfall —, weiß der Client nicht, ob
seine Änderung ankam. Der Server merkt sich je Dokument die zuletzt
verarbeitete `seq` pro `clientId` und beantwortet eine Wiederholung mit dem
Ergebnis von damals, statt sie erneut anzuwenden.
**Zeilenenden:** Der **Server** normalisiert beim Speichern autoritativ auf
LF (SPEC §12); der Client normalisiert beim Laden ebenfalls. Nur so hashen
beide Seiten denselben Text.
Werkbaum-Besonderheiten sind mit dem Zeilenmodell automatisch abgedeckt:
Fortsetzungszeilen (` \`), `"`-Beschreibungszeilen und der
`---`-Beschreibungsteil sind schlicht physische Zeilen. Halbfertige
Zwischenzustände rendert Werkbaum mit Warnung weiter — zeilenweise Updates
sind hier risikoarm.
**Faltmarken sind gewöhnliche Zeileninhalte.** Klappt jemand im Diagramm
einen Teilbaum um, schreibt Werkbaum das als `>`/`<` in den Text zurück
(D38-Nachtrag 2); unter Live-Editing wird daraus ein ganz normaler PATCH,
den alle Beobachter sehen. Das ist bewusst so entschieden (D76): Der Text
bleibt die eine Quelle der Wahrheit (D14). Auch der Falt-Durchschalter (D75)
bekommt keine Sonderbehandlung, obwohl ein Druck den ganzen Baum umbaut.
## API-Erweiterung (OpenAPI-Spec)
@@ -67,11 +112,27 @@ weiter — zeilenweise Updates sind hier risikoarm.
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**:
- **200 OK**: akzeptiert. Antwort:
```json
{ "version": 42, "opsSinceBase": [ … ] }
```
`opsSinceBase` ist leer, wenn die Basis noch aktuell war. War sie es nicht
und ließen sich die Ops verschieben (siehe unten), stehen hier die fremden
Ops, damit der Client seine Schattenkopie nachzieht.
- **Der Server rebased selbst.** Ist `baseVersion` veraltet, überschneiden
sich die eingereichten Ops aber **nicht** mit den zwischenzeitlichen,
verschiebt der Server sie auf die aktuelle Version und akzeptiert. Grund:
Reines Ablehnen führt zu **Starvation** — ein Client mit höherer Latenz
kommt bei fleißigen Mitschreibern womöglich nie durch, weil jeder Versuch
beim Eintreffen wieder veraltet ist. Genau deshalb hat CodeMirror sein
`rebaseUpdates` nachgerüstet. Nebengewinn: Der Client braucht seine
Rebase-Logik nur noch für echte Konflikte.
- **409 Conflict**: nur bei **echter Überlappung**. Antwort enthält alles,
was der Client zum Weiterarbeiten braucht — ohne Neuladen:
```json
{
@@ -80,60 +141,115 @@ Request: das Diff-Objekt oben.
}
```
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.
Der Client zeigt dann zwei Knöpfe: **fremde Fassung übernehmen** oder
**eigene durchsetzen** — jeweils nur für die überlappenden Zeilen, alles
übrige ist bereits rebased. An dieser Stelle gewinnt einer vollständig,
aber nichts geht endgültig verloren: Jede Version steht in der Historie.
`PUT /documents/{id}` bleibt als „Ganzdokument ersetzen" bestehen
(Import, Reparatur), wertet aber künftig `expectedVersion` aus.
- **404**: Dokument gelöscht (Restore-Hinweis in der Problem-Detail-Antwort).
- **422**: Diff nicht anwendbar (Index außerhalb, **Prüfsummenfehler**) —
deutet auf einen Client-Bug, Client lädt einmalig neu.
**Überlappung, genau definiert.** Der betroffene Bereich einer Op ist
`[index, index+count)` für `replace` und `delete`. Für `insert` ist er ein
**Punkt** bei `index` — nicht ein leeres Intervall, sonst überschnitte er
sich mit nichts und Einfüge-Konflikte blieben unerkannt. Daraus folgt:
- Zwei Einfügungen an derselben Stelle sind **kein** Konflikt. Beide Zeilen
bleiben; die bereits bestätigte fremde steht oben.
- Eine Einfügung in einen Bereich, den ein anderer **löscht**, ist einer —
die neue Zeile landete sonst in einem Abschnitt, den es nicht mehr gibt.
**Titel:** `PATCH /documents/{id}/title` (mit `expectedVersion`) ändert den
Titel; er ist ein Metadatum, kein Zeileninhalt. `PUT /documents/{id}` bleibt
als „Ganzdokument ersetzen" bestehen (Import, Reparatur) und wertet künftig
`expectedVersion` aus.
**Grenzen:** Dokumentgröße und Op-Anzahl je Request sind serverseitig
begrenzt (sonst ist ein einzelner Request ein Ausfall-Vektor, auch
versehentlich durch einen Client-Bug).
### 2. `GET /documents/{id}/changes?since={version}&wait={seconds}` — Änderungsfeed
Long Polling, der Kern der „Echtzeit ohne WebSocket"-Lösung:
Long Polling, der Kern der „Echtzeit ohne WebSocket"-Lösung.
- Gibt es bereits Änderungen nach `since`: **sofort 200** mit
**Der Feed arbeitet auf der Historie, nicht am Dokument.** `delete()` entfernt
das Dokument und lässt nur den Tombstone stehen — ein Feed am Dokument müsste
danach 404 liefern, ausgerechnet für das `DELETED`-Ereignis, das er zustellen
soll. Solange es Historieneinträge zur UUID gibt, antwortet der Feed also;
**404 nur bei gänzlich unbekannter UUID** (dieselbe Regel, die `history()`
schon anwendet). Wartende Long-Polls müssen beim Löschen **zugestellt
bekommen**, bevor die Warteliste verworfen wird.
- Gibt es Änderungen nach `since`: **sofort 200** mit
```json
{
"fromVersion": 41,
"currentVersion": 43,
"ops": [ ...kumuliertes Diff 41 → 43... ],
"events": [ { "version": 43, "changeType": "UPDATED" } ]
"events": [
{ "version": 43, "changeType": "UPDATED",
"clientId": "c-8a41…", "displayName": "Anna" }
]
}
```
- 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.
- **Ist `since` bereits verdichtet** (siehe „Zwei Ebenen" unten), kann der
Server kein exaktes Diff mehr liefern. Dann enthält die Antwort statt `ops`
den **Volltext**:
```json
{ "fromVersion": null, "currentVersion": 87, "content": "…", "events": [ … ] }
```
Der Client ersetzt seinen Stand. Ein Roundtrip und ein Sonderzustand
weniger als ein eigener Fehlerpfad — und der Cursor-Erhalt ist über
hunderte Versionen hinweg ohnehin nicht zu retten. Deckt zugleich den
PWA-Fall nach längerer Offline-Zeit ab.
- Sonst hält der Server die Anfrage bis zu `wait` Sekunden offen. Kommt in
der Zeit eine Änderung, antwortet er sofort; sonst **204 No Content**, und
der Client pollt erneut. Der `wait`-Wert wird **serverseitig geklemmt** —
ein Client darf keine beliebig lange Verbindung binden. Die Obergrenze
steht erst fest, wenn die Zielumgebung vermessen ist (siehe „Betrieb").
- Die Antwort trägt **`Cache-Control: no-store`**; ein Proxy dürfte sonst
eine 204 zwischenspeichern und der Feed stünde still.
- 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.
**Änderungstypen im Feed:** `UPDATED`, `DELETED`, `RESTORED`, `ROLLED_BACK`
und `RENAMED` (dieses mit dem neuen Titel im Klartext).
- **`RESTORED` heißt ausschließlich: ein gelöschtes Dokument ist wieder da** —
der Client hebt seine Sperre auf.
- **`ROLLED_BACK`** ist der Rückfall eines *lebenden* Dokuments auf eine alte
Version (`restore` mit `targetVersion`). Für den Client ist das ein
gewöhnlicher Inhaltswechsel; er hatte nie eine Sperre. Bisher trugen beide
Fälle denselben Typ — ein Typ, der zwei Dinge bedeutet, ist die Unschärfe,
aus der später Fehler werden.
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
wenn 200 mit ops: ops lokal anwenden, meineVersion = currentVersion
wenn 200 mit content: Stand ersetzen, meineVersion = currentVersion
wenn 204: weiter
```
**Wichtig für den Client:** Eine gepufferte Feed-Antwort darf **nur**
angewendet werden, wenn ihr `fromVersion` zur aktuellen Schattenkopie passt.
Sonst wendet er dieselben Ops doppelt an — der Fall tritt ein, wenn Feed und
409-Antwort beide dasselbe fremde Diff liefern.
### 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,
Request/Response-HTTP ist — testbar mit unseren Cucumber-Tests,
(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;
@@ -141,50 +257,128 @@ 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 anwenden**: Snapshot der Basisversion laden (bzw. aktueller Stand,
wenn `baseVersion == currentVersion`, der Normalfall), Prüfsumme
vergleichen, 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.
Snapshots (Myers oder eine einfache LCS-Implementierung).
- **Rebase** (siehe PATCH): Ops einer veralteten Basis gegen die
zwischenzeitlichen verschieben, sofern sie sich nicht überschneiden.
- **Long Polling**: Spring MVC `DeferredResult` + ein In-Process-Notifier
(pro Dokument eine Warteliste; `notifyAll` bei akzeptiertem Update).
Kein zusätzliches Framework nötig.
(pro Dokument eine Warteliste; Zustellung bei akzeptiertem Update **und**
beim Löschen). Kein zusätzliches Framework nötig. **Das setzt eine
Einzelinstanz voraus** — hinter einem Load Balancer erführe ein Beobachter
auf der zweiten Instanz nichts und liefe in den Timeout. Bewusste Annahme,
für die genannte Last angemessen.
- **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.
(Locking pro Dokument-UUID), damit Prüfung, Rebase und Anwenden atomar sind.
### Historie in zwei Ebenen
Mit 1,5 s Debounce wird die Historie sonst zum Transaktionslog: hunderte
Volltext-Snapshots eines 40-kB-Dokuments je Sitzung. Getrennt werden deshalb:
- **Sync-Versionen** — tragen das Protokoll (Diffs zwischen beliebigen
Versionen), kurzlebig, werden nach einer Weile verdichtet. Danach
beantwortet der Feed betroffene `since`-Werte mit Volltext (oben).
- **Meilensteine** — die nutzersichtbare Historie. Sie entstehen **nach einer
Schreibpause und auf Knopfdruck**; dasselbe Muster wie die „Früheren
Stände" im Editor (D54), erprobt und den Nutzern vertraut.
`DocumentHistoryRepository` braucht dafür **gezielten Zugriff auf eine
einzelne Version** statt wie heute stets alle Einträge zu laden
(`findByDocumentId` liefert alles, `restore`/`history` filtern in Kotlin
darüber — bei hunderten Versionen je Dokument untragbar).
## Betrieb — Zielumgebung vermessen (2026-08-26)
Gemessen auf `mih00.hostsharing.net`, wo die stabile Instanz
`werkbaum.javagil.de` liegt (D43):
| Befund | Wert | Bedeutung für Long Polling |
|---|---|---|
| Apache | 2.4.68 (Debian 12) | — |
| MPM | **event** | Kein Prozess je Verbindung; die Sorge aus dem ersten Entwurf entfällt |
| MaxRequestWorkers | **1024** (ServerLimit 64 × 32) | 10 offene Polls sind unkritisch |
| `Timeout` | **300 s** | `wait=25` liegt weit darunter |
| `ProxyTimeout` | nicht gesetzt → 300 s | dito |
| mod_proxy_http, mod_rewrite | geladen | Reverse Proxy technisch möglich |
| HTTP/2 | **nicht angeboten** | siehe unten |
| Java | **nur 17** | Backend verlangt 21 |
| systemd `Linger` | **yes** | Ein eigener Dienst darf dauerhaft laufen |
| PostgreSQL | lauscht auf 5432 | Der in `application.yaml` angedachte Umstieg wäre möglich |
**Long Polling trägt dort.** Die Zeitgrenzen sind großzügig, und der
Worker-Pool ist groß genug. **Pufferung ist bei Long Polling ohnehin kein
Thema** — anders als bei SSE kommt genau eine Antwort am Ende des Wartens,
kein Strom von Teilstücken.
**Der einzige echte Einwand ist das fehlende HTTP/2.** Damit gilt im Browser
das Limit von sechs Verbindungen je Herkunft. Ein Long-Poll belegt eine
davon; hat jemand denselben Plan in drei Tabs offen, sind drei Verbindungen
dauerhaft gebunden und die übrigen Requests drängen sich in den Rest. Zu
beheben wäre es serverseitig (HTTP/2 aktivieren) oder clientseitig, indem
sich mehrere Tabs über einen SharedWorker **eine** Feed-Verbindung teilen.
**Zwei offene Punkte vor einem Deployment dorthin:**
1. **Java 17 statt 21.** `build.gradle.kts` verlangt `JavaLanguageVersion.of(21)`.
Entweder die Toolchain auf 17 senken (dann fallen Sprachfeatures weg) oder
ein eigenes JDK 21 ins Home legen — beim Selfhosting problemlos, aber es
muss jemand tun.
2. **Der Weg vom Apache zum Backend ist ungeklärt.** `ProxyPass` ist in
`.htaccess` nicht zulässig, und `~/doms/<domain>/etc/` ist leer, sodass
unklar bleibt, ob dort eigene vhost-Direktiven abgelegt werden können.
Möglich wären `RewriteRule … [P]` (mod_rewrite ist aktiv, das P-Flag
sperren manche Hoster jedoch) oder eine Rückfrage bei Hostsharing. **Nicht
getestet**, weil dafür eine Proxy-Regel in der Produktionsumgebung
einzurichten wäre.
Die Lehre aus D17-Nachtrag 4 bleibt: Was die Umgebung stellt, stellt der
Emulator nicht — die Zahlen oben sind gemessen, der Proxy-Pfad ist es nicht.
## 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).
- Konflikterkennung auf **Dokumentebene** mit serverseitigem Rebase: Zwei
Editoren an verschiedenen Stellen stören einander nicht mehr; ein 409 gibt
es nur bei echter Überlappung derselben Zeilen.
- 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.
leichtgewichtiges `presence`-Feld im Feed nachrüstbar — `clientId` und
Anzeigename gibt es dafür schon; das `!!!`-Fokusmark des Formats kann
ergänzend 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).
nur das serverseitige Diff-Berechnen und Rebasen 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".
„veraltete Basis ohne Überlappung wird serverseitig rebased und akzeptiert",
veraltete Basis mit Überlappung liefert 409 mit opsSinceBase",
„derselbe PATCH zweimal gesendet ändert das Dokument nur einmal",
„falsche Prüfsumme liefert 422", „Feed meldet DELETED und danach RESTORED",
„zu altes since liefert Volltext", „Umbenennen erscheint als RENAMED".
Long Polling braucht dafür **Nebenläufigkeit im Test** (zwei Threads oder
asynchrones MockMvc) und einen klein konfigurierbaren `wait`-Wert — der
heutige synchrone `TestRestTemplate`-Stil allein reicht nicht.
- **Unit-Tests**: Diff-Anwendung (alle drei Ops, Randfälle: leeres Dokument,
Anhängen, letzte Zeile), Diff-Berechnung, Index-Verschiebung.
Anhängen, letzte Zeile), Diff-Berechnung, Index-Verschiebung, die
Überlappungsregeln für `insert` (untereinander verträglich, mit `delete`
nicht), Prüfsummenbildung samt Zeilenenden-Normalisierung.
## Vorschlag Umsetzungsreihenfolge
## 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)
1. Diff-Modell + Anwenden/Berechnen/Rebasen als reine Kotlin-Funktionen
(Unit-Tests)
2. Historie in zwei Ebenen + gezielter Repository-Zugriff
3. `PATCH /content` inkl. Rebase, Idempotenz, Prüfsumme und 409 (Spec +
Cucumber)
4. `GET /changes` mit Long Polling, Volltext-Fall und Ereignistypen
(Spec + Cucumber)
5. Master-Passwort für `GET /documents` (Spring Security)
6. Client-Anpassung (Feed-Schleife, lokales Anwenden, Konfliktdialog)
**Vor Schritt 4** steht die Vermessung der Zielumgebung (siehe „Betrieb") —
sie bestimmt den `wait`-Wert und im Extremfall, ob Long Polling dort
überhaupt trägt.
+193
View File
@@ -5744,3 +5744,196 @@ und 0 Kanten im exportierten SVG, an → 3/3; `depLinks` überlebt den Reload.
inkl. des per `:#…` gezogenen Ziels unter einer Zugabe; `data-sub-*` in
Dokumentreihenfolge, dedupliziert, nie an offenen Knoten, nie für
ausgeblendete verworfene).
## D76 — Live-Editing über HTTP: die offenen Punkte der beiden Konzepte entschieden
Zu `backend/docs/live-editing-proposal.md` und
`backend/docs/client-live-editing-instructions.md` (beide Entwurf, nichts
implementiert). Die Konzepte sind in ihrem Kern schlüssig — zeilenbasierte
Diffs gegen eine Basisversion, Optimistic Locking auf Dokumentebene, Long
Polling statt WebSocket. Offen waren die Ränder: Löschen und
Wiederherstellen, Wiederholungen nach Netzwerkfehlern, das Verhältnis zum
tatsächlichen Frontend — und die Frage, was im Werkbaum-Editor überhaupt
eine Textänderung ist. Die Antworten, jeweils mit dem Grund:
**Faltung wird geteilt.** Nach D38-Nachtrag 2 schreibt jedes Klappen eine
Faltmarke in den Text zurück; unter Live-Editing wird daraus ein PATCH, der
bei allen ankommt. Erwogen war, sie bei geteilten Dokumenten wie bei Pads
(D31) nur sitzungsweise zu überlagern. Entschieden ist das Gegenteil: Der
Text bleibt die eine Quelle der Wahrheit (D14), und „was du siehst, steht
geschrieben" gilt uneingeschränkt. Auch der Falt-Durchschalter (D75)
bekommt **keine** Sonderbehandlung, obwohl ein Druck den ganzen Baum
umbaut. Sollte sich das im Betrieb als störend erweisen, ist die ehrliche
Antwort, Faltung insgesamt persönlich zu machen — nicht eine Ausnahme für
einen einzelnen Knopf.
**Zugriff über die unerratbare UUID, wie ein Pad-Link.** Kein Login, kein
Rechtemodell; das Protokoll bleibt davon unberührt, echte Authentifizierung
kann später als Schicht davor. Das kollidierte mit dem vorhandenen
`GET /documents`, das sämtliche Dokumente samt Inhalt auflistet und damit
jede UUID auffindbar macht — der Schutz wäre hinfällig gewesen. Dieser
Endpunkt verlangt deshalb ein **Master-Passwort**, dessen Hash serverseitig
in einer Umgebungsvariable liegt; geprüft wird mit **Spring Security**
(erste Abhängigkeit dieser Art, bewusst: sie ist zugleich der Platz für die
spätere richtige Authentifizierung). Ein einzelnes Passwort auf einem
offenen Endpunkt braucht eine Sperre nach Fehlversuchen, und die
Übertragung setzt HTTPS voraus.
**Identität pseudonym: Client-ID plus selbstgewählter Anzeigename** — das
Etherpad-Modell (D31). Ohne Anmeldung ist der Name nur eine Behauptung und
darf nicht wie ein Nachweis aussehen; er trägt aber vier Dinge zugleich:
Wiedererkennung beim Retry, „geändert von" in der Historie, eine
deterministische Reihenfolge bei gleichzeitigen Einfügungen und spätere
Präsenz.
**Der Feed arbeitet auf der Historie, nicht am Dokument.** `delete()`
entfernt das Dokument und lässt nur den Tombstone stehen — ein Feed am
Dokument müsste danach 404 liefern, ausgerechnet für das DELETED-Ereignis,
das er zustellen soll. Solange es Historieneinträge zur UUID gibt,
antwortet der Feed also; 404 nur bei gänzlich unbekannter UUID, dieselbe
Regel wie `history()` sie schon anwendet. Wartende Long-Polls müssen beim
Löschen zugestellt bekommen, bevor die Warteliste verworfen wird.
**Die Historie bekommt zwei Ebenen.** Mit 1,5 s Debounce wird sie sonst zum
Transaktionslog: hunderte Volltext-Snapshots eines 40-kB-Dokuments je
Sitzung. Getrennt werden kurzlebige **Sync-Versionen** (tragen das
Protokoll) und nutzersichtbare **Meilensteine**. Letztere entstehen nach
einer Schreibpause und auf Knopfdruck — dasselbe Muster wie die „Früheren
Stände" im Editor (D54), erprobt und den Nutzern vertraut. Bei
Server-Dokumenten zeigt der Verlaufs-Knopf künftig die **Server**-Meilensteine
statt der lokalen Stände: gleiche Bedienung, bessere Quelle (geteilt,
überlebt Geräte- und Browserwechsel). Lokale Stände wären dort sogar
irreführend, weil „mein Stand von vorhin" fremde Änderungen enthält, die man
nie gesehen hat.
**Nachzügler bekommen den Volltext.** Ist `since` bereits verdichtet, kann
der Server kein exaktes Diff mehr liefern. Statt eines eigenen Fehlerpfads
enthält die Feed-Antwort dann den kompletten Inhalt samt Version — ein
Roundtrip und ein Zustand weniger im Client, und der Cursor-Erhalt ist über
hunderte Versionen hinweg ohnehin nicht zu retten. Deckt zugleich den
PWA-Fall nach längerer Offline-Zeit ab.
**Der Server rebased selbst; 409 nur bei echter Überlappung.** Das Proposal
lehnte jeden veralteten PATCH ab. Das führt zu **Starvation**: Ein Client
mit höherer Latenz kommt bei fleißigen Mitschreibern womöglich nie durch,
weil jeder Versuch beim Eintreffen wieder veraltet ist — genau deswegen hat
CodeMirror sein `rebaseUpdates` nachgerüstet. Überschneiden sich die Ops
nicht mit den zwischenzeitlichen, verschiebt der Server sie also selbst und
antwortet mit 200; die Antwort liefert die fremden Ops mit, damit der Client
seine Schattenkopie nachzieht. Nebengewinn: Der aufwendigste Teil der
Client-Instruktion (§4, Rebase) wird nur noch für echte Konflikte gebraucht.
**Wiederholte Patches werden erkannt.** Geht die Antwort verloren — im
Mobilnetz der Normalfall —, weiß der Client nicht, ob seine Änderung ankam;
ein Retry würde sie über den Rebase ein zweites Mal anwenden. Der PATCH
trägt deshalb Client-ID und eine **laufende Nummer**, und der Server
beantwortet eine Wiederholung mit dem Ergebnis von damals.
**Einfügungen kollidieren untereinander nicht, mit Löschungen schon.** Die
Client-Instruktion definierte den Bereich einer Op halboffen als
`[index, index+count)`, für insert aber `[index, index]` — das ist die leere
Menge und schneidet nichts, Einfüge-Konflikte wären nie erkannt worden. Zwei
Einfügungen an derselben Stelle sind kein Konflikt: Beide Zeilen bleiben, die
bereits bestätigte fremde steht oben. Eine Einfügung in einen Bereich, den
ein anderer löscht, ist einer.
**Rollback bekommt einen eigenen Änderungstyp.** `restore()` tut zweierlei —
ein gelöschtes Dokument wiederherstellen und ein lebendes auf eine alte
Version zurücksetzen —, beides bisher als `RESTORED`. Der Client soll bei
`RESTORED` die Sperre aufheben; beim Rollback gab es nie eine Sperre, dort
ändert sich nur der Inhalt. Ein Typ, der zwei Dinge bedeutet, ist die
Unschärfe, aus der später Fehler werden.
**Der Titel läuft mit.** Er ist ein Metadatum, kein Zeileninhalt, und bekommt
einen eigenen Weg mit Versionsprüfung; das Feed-Ereignis führt den neuen
Titel im Klartext mit. Bisher hätte ein Umbenennen eine Version erzeugt, von
der niemand etwas erfährt.
**Prüfsumme als Pflichtfeld.** Das Proposal sah sie optional vor, die
Client-Instruktion kannte sie gar nicht. Optional ist die schlechteste
Variante — die Kosten der Spezifikation ohne den Nutzen. Die Versionsnummer
bestätigt nur, dass die Basis dieselbe Version ist, nicht dass beide Seiten
sie gleich lesen; ein Index-Versatz zerstört Text sonst unbemerkt, und dieses
Projekt zieht durchweg den lauten Fehler dem stillen vor (D59, SPEC §4).
Damit beide dasselbe hashen, normalisiert der **Server** Zeilenenden beim
Speichern autoritativ auf LF (SPEC §12), der Client beim Laden ebenfalls.
**Der Client bleibt bei der Textarea.** Die Instruktion nennt CodeMirror 6
oder Monaco als bevorzugten Weg und die Textarea als Fallback — bei Werkbaum
ist sie der einzige Fall (D49), und `dependencies` ist leer. Gemessen kostet
CodeMirror **120 kB gzip** für den Einstieg (Editor, Zeilennummern, Undo),
voll ausgestattet 138 kB, gegen aktuell 294 kB Bundle. Bemerkenswert: Der
Einstieg ist teuer, alles Weitere danach fast umsonst — Syntax-Hervorhebung
und Autovervollständigung kosten zusammen 18 kB. Es gäbe echte Gewinne (die
`execCommand`-Altlast aus D53/D55 verschwände, Zeilennummern und
Autovervollständigung wären eingebaut, der Spiegel-Div entfiele), aber der
Umbau berührt ein Dutzend Entscheidungen und darf nicht die Nebenwirkung
eines anderen Features sein. CodeMirror bleibt eine **eigene Frage**.
`@codemirror/collab` hilft dabei ohnehin nicht: Es arbeitet mit
CodeMirror-ChangeSets und rebased **serverseitig**, setzt also JavaScript auf
dem Server voraus.
**Die Diff-Berechnung wird selbst implementiert**, nicht per jsdiff — rund
hundert Zeilen über die längste gemeinsame Teilfolge, in derselben
Größenordnung wie der Zeilenumbruch aus D64. Dieselbe Grenze wie bei
CodeMirror: keine Laufzeit-Abhängigkeit (D11/D19/D20).
**Adressiert wird über einen URL-Parameter**, wie `?sourceUrl=` (D23) und
`?etherpad=` (D31): Die Identität leitet sich aus der URL ab, derselbe Link
führt immer in dasselbe Dokument, der Name ist die URL. Angelegt wird über
einen Menüeintrag „Auf den Server legen", analog zu „Als Datei speichern"
(D72). Kein neues Bedienkonzept, und ein geteiltes Dokument wird ohnehin per
Link geteilt.
**Debounce bleibt bei 1,5 s.** Die Rate-Limit-Disziplin des Proposals stammt
von Etherpad — einem fremden Server mit 10 Abrufen je 90 s (D31). Am eigenen
Backend gelten die eigenen Grenzen, und die Last ist gering: ein Request alle
1,5 s ausschließlich während aktiven Tippens. Kleine Diffs und seltene
Überschneidungen sind das wert.
**Im Konfliktfall zwei klar benannte Knöpfe** — fremde Fassung übernehmen
oder eigene durchsetzen, jeweils nur für die überlappenden Zeilen; alles
übrige wird ohnehin rebased. Einer gewinnt dort vollständig, aber nichts geht
endgültig verloren: Jede Version steht in der Historie.
**Der Betrieb wird gemessen, bevor er festgeschrieben wird.** Long Polling
hält je Beobachter eine Verbindung offen; hinter dem Apache der stabilen
Instanz (D43) sind `ProxyTimeout`, Pufferung und das Worker-Modell offene
Fragen, ebenso das Browser-Limit von sechs Verbindungen je Herkunft bei
mehreren Tabs. Der `wait`-Wert von 25 s steht erst fest, wenn die
Zielumgebung vermessen ist — die Lehre aus D17-Nachtrag 4: Was die Umgebung
stellt, stellt der Emulator nicht.
**Ohne eigene Entscheidung festgehalten**, weil alternativlos: Gepufferte
Feed-Antworten werden nur angewendet, wenn ihr `fromVersion` zur aktuellen
Schattenkopie passt — sonst wendet ein Client dieselben Ops doppelt an, wenn
Feed und 409-Antwort sie beide liefern. Der In-Process-Notifier setzt eine
**Einzelinstanz** voraus. Der Feed braucht `Cache-Control: no-store` und eine
serverseitige Obergrenze für `wait`. PATCH braucht ein Größenlimit für
Dokument und Op-Anzahl. `DocumentHistoryRepository` braucht gezielten Zugriff
auf eine einzelne Version, statt wie heute stets alle Einträge zu laden. Und
die im Proposal zitierte „Speicher-Evaluation" existiert im Repo nicht — sie
gehört nachgeliefert oder der Verweis aufgelöst.
**Nachtrag — die Zielumgebung ist vermessen (2026-08-26).** Gemessen auf
`mih00.hostsharing.net`: Apache 2.4.68 mit **MPM event** und
MaxRequestWorkers **1024**, `Timeout 300` und kein gesetztes `ProxyTimeout`,
mod_proxy_http und mod_rewrite geladen, systemd-`Linger=yes` (ein eigener
Dienst darf also dauerhaft laufen), PostgreSQL auf 5432 vorhanden.
**Long Polling trägt dort**: Die Zeitgrenzen sind großzügig, zehn offene
Verbindungen sind bei 1024 Workern unkritisch, und die Sorge vor einem
Prozess je Verbindung war unbegründet — sie gilt mpm_prefork, hier läuft
event. Auch Pufferung ist kein Thema: Anders als bei SSE kommt genau eine
Antwort am Ende des Wartens.
Drei Befunde bleiben als Arbeit stehen. **Kein HTTP/2** — damit gilt das
Browser-Limit von sechs Verbindungen je Herkunft, und mehrere Tabs derselben
Person binden je eine dauerhaft; zu beheben serverseitig oder clientseitig
über einen SharedWorker, der allen Tabs eine gemeinsame Feed-Verbindung gibt.
**Nur Java 17** installiert, während `build.gradle.kts`
`JavaLanguageVersion.of(21)` verlangt. Und der **Weg vom Apache zum Backend
ist ungeklärt**: `ProxyPass` ist in `.htaccess` nicht zulässig,
`~/doms/<domain>/etc/` ist leer; bliebe `RewriteRule … [P]` oder eine
Rückfrage beim Hoster. Dieser letzte Punkt ist bewusst **nicht** getestet —
dafür hätte eine Proxy-Regel in der Produktionsumgebung eingerichtet werden
müssen. Details in `backend/docs/live-editing-proposal.md`, Abschnitt
„Betrieb".