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:
co-authored by
Claude Opus 5
parent
40db41531e
commit
2540bbf0a7
@@ -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 `fromVersion → currentVersion`,
|
||||
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
|
||||
|
||||
@@ -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 **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).
|
||||
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 Konfliktprü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.
|
||||
|
||||
Reference in New Issue
Block a user