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
|
# Aufgabe: Live-Editing-Client für den Werkbaum-Editor implementieren
|
||||||
|
|
||||||
Du arbeitest im Repository der Werkbaum-Web-App (PWA). Implementiere die
|
Du arbeitest im **Werkbaum-Monorepo**, Teilprojekt `frontend/`. Implementiere
|
||||||
Client-Seite des Live-Editing-Protokolls gegen das Editor-Backend. Das
|
die Client-Seite des Live-Editing-Protokolls gegen das Editor-Backend
|
||||||
Protokoll ist HTTP-only (kein WebSocket): Änderungen werden als
|
(`backend/`). Das Protokoll ist HTTP-only (kein WebSocket): Änderungen werden
|
||||||
zeilenbasierte Diffs per PATCH eingereicht, andere Clients erhalten sie über
|
als zeilenbasierte Diffs per PATCH eingereicht, andere Clients erhalten sie
|
||||||
einen Long-Polling-Feed.
|
über einen Long-Polling-Feed.
|
||||||
|
|
||||||
**Wichtiger Kontext:** Die beiden Live-Editing-Endpunkte (`PATCH …/content`
|
**Wichtiger Kontext:** Die Live-Editing-Endpunkte sind spezifiziert
|
||||||
und `GET …/changes`) sind im Backend spezifiziert, aber ggf. noch nicht
|
(`backend/docs/live-editing-proposal.md`, Entscheidungen in
|
||||||
deployt. Implementiere gegen den hier definierten Vertrag und baue einen
|
`docs/DECISIONS.md` D76), aber noch nicht implementiert. Baue gegen den hier
|
||||||
Mock-Server (oder MSW-Handler) für die Tests. Die CRUD-Endpunkte existieren
|
definierten Vertrag; die CRUD-Endpunkte existieren bereits.
|
||||||
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
|
## 1. Backend-Vertrag
|
||||||
|
|
||||||
Basis-URL: konfigurierbar (`VITE_BACKEND_URL` o. Ä.), Pfad-Präfix `/api/v1`.
|
Basis-URL: konfigurierbar, Pfad-Präfix `/api/v1`. Alle Bodies sind JSON.
|
||||||
Alle Bodies sind JSON. Fehler kommen als RFC-9457 `application/problem+json`.
|
Fehler kommen als RFC-9457 `application/problem+json`.
|
||||||
|
|
||||||
### 1.1 Bestehende Endpunkte (bereits verfügbar)
|
### 1.1 Bestehende Endpunkte
|
||||||
|
|
||||||
- `GET /documents/{uuid}` →
|
- `GET /documents/{uuid}` →
|
||||||
`{ id, title, content, version, createdAt, updatedAt }`
|
`{ id, title, content, version, createdAt, updatedAt }`
|
||||||
- `content`: das komplette Werkbaum-Dokument als ein String, LF-getrennt.
|
- `content`: das komplette Werkbaum-Dokument als ein String, LF-getrennt.
|
||||||
- `version`: Long, wird serverseitig bei jeder Änderung inkrementiert.
|
- `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.
|
- `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
|
### 1.2 `PATCH /documents/{uuid}/content` — Änderung einreichen
|
||||||
|
|
||||||
Request:
|
Request:
|
||||||
@@ -35,6 +62,9 @@ Request:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"baseVersion": 41,
|
"baseVersion": 41,
|
||||||
|
"checksum": "sha256:9f2b…",
|
||||||
|
"clientId": "c-8a41…",
|
||||||
|
"seq": 17,
|
||||||
"ops": [
|
"ops": [
|
||||||
{ "op": "replace", "index": 12, "count": 1, "lines": [" - [~] Backend (L) @ben"] },
|
{ "op": "replace", "index": 12, "count": 1, "lines": [" - [~] Backend (L) @ben"] },
|
||||||
{ "op": "insert", "index": 20, "lines": [" + [?] Dark mode (S)"] },
|
{ "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:
|
Antworten:
|
||||||
|
|
||||||
| Status | Bedeutung | Body |
|
| Status | Bedeutung | Body |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 200 | akzeptiert | `{ "version": 42 }` |
|
| 200 | akzeptiert | `{ "version": 42, "opsSinceBase": [ … ] }` |
|
||||||
| 409 | `baseVersion` veraltet | `{ "currentVersion": 43, "opsSinceBase": [ …Ops… ] }` |
|
| 409 | echte Überlappung | `{ "currentVersion": 43, "opsSinceBase": [ … ] }` |
|
||||||
| 404 | Dokument gelöscht | problem+json |
|
| 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).
|
**Der Server rebased selbst.** War `baseVersion` veraltet, überschneiden sich
|
||||||
Nach 422: Dokument einmalig komplett neu laden (GET) und Zustand ersetzen —
|
die Ops aber nicht mit den zwischenzeitlichen, verschiebt der Server sie und
|
||||||
das ist der einzige zulässige Vollreload-Pfad.
|
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
|
### 1.3 `GET /documents/{uuid}/changes?since={version}&wait=25` — Feed
|
||||||
|
|
||||||
- 200: `{ "fromVersion": 41, "currentVersion": 43, "ops": [ … ], "events": [ { "version": 43, "changeType": "UPDATED" } ] }`
|
- **200 mit `ops`**:
|
||||||
- `ops` ist das **kumulierte** Diff `fromVersion → currentVersion`,
|
`{ "fromVersion": 41, "currentVersion": 43, "ops": [ … ], "events": [ … ] }`
|
||||||
direkt anwendbar auf den lokalen Stand, wenn `since == fromVersion`.
|
- `ops` ist das **kumulierte** Diff `fromVersion → currentVersion`.
|
||||||
- `changeType` ∈ `CREATED | UPDATED | DELETED | RESTORED`.
|
- **200 mit `content`**:
|
||||||
- 204: Timeout ohne Änderungen → sofort erneut pollen.
|
`{ "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
|
- Netzwerkfehler/Timeout des Browsers: mit Exponential Backoff
|
||||||
(1 s, 2 s, 4 s … max 30 s) erneut versuchen; bei Erfolg Backoff zurücksetzen.
|
(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`
|
`events` trägt je Eintrag `{ version, changeType, clientId, displayName }`,
|
||||||
Sperre aufheben.
|
`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.
|
bedeutet anhängen.
|
||||||
- `delete`: entfernt `count` Zeilen ab `index`.
|
- `delete`: entfernt `count` Zeilen ab `index`.
|
||||||
|
|
||||||
**Anwenden:** entweder rückwärts iterieren (höchster Index zuerst), dann
|
**Anwenden:** `applyOps(lines, ops)` als pure Funktion, **rückwärts
|
||||||
bleiben die Basis-Indizes gültig — oder vorwärts mit mitlaufendem Offset.
|
iterierend** (höchster Index zuerst) — dann bleiben die Basis-Indizes gültig
|
||||||
Implementiere `applyOps(lines: string[], ops: Op[]): string[]` als pure
|
und die Korrektheit ist leicht zu sehen.
|
||||||
Funktion, rückwärts iterierend (einfacher zu beweisen).
|
|
||||||
|
|
||||||
**Erzeugen:** implementiere `computeOps(before: string[], after: string[]): Op[]`
|
**Erzeugen:** `computeOps(before, after)` **selbst implementieren** — ein
|
||||||
mit einem Standard-Zeilen-Diff (Myers; eine kleine Bibliothek wie `diff`
|
zeilenbasiertes Diff über die längste gemeinsame Teilfolge, rund hundert
|
||||||
[jsdiff] mit `diffArrays` ist ok, dann Hunks in unsere drei Op-Typen
|
Zeilen (dieselbe Größenordnung wie der Zeilenumbruch aus D64). Zeilen für den
|
||||||
übersetzen). Aufeinanderfolgende delete+insert am selben Index zu `replace`
|
Vergleich als Ganzes behandeln; aufeinanderfolgende delete+insert am selben
|
||||||
zusammenfassen.
|
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):**
|
**Invarianten (als Tests absichern):**
|
||||||
- `applyOps(before, computeOps(before, after)) ≡ after` (Property-Test mit
|
- `applyOps(before, computeOps(before, after)) ≡ after` — Property-Test mit
|
||||||
zufälligen Zeilen-Arrays, unbedingt mit Duplikaten und Leerzeilen).
|
zufälligen Zeilen-Arrays, unbedingt mit Duplikaten und Leerzeilen.
|
||||||
- Leeres Diff (`ops: []`) wird gar nicht erst gesendet.
|
- Leeres Diff (`ops: []`) wird gar nicht erst gesendet.
|
||||||
|
|
||||||
**Zeilenenden:** beim Laden und vor jedem `computeOps` normalisieren:
|
**Zeilenenden:** beim Laden und vor jedem `computeOps` normalisieren:
|
||||||
`content.replace(/\r\n?/g, "\n")`. Kein trailing-newline-Sonderfall:
|
`content.replace(/\r\n?/g, "\n")`. Der Server normalisiert beim Speichern
|
||||||
`split("\n")` auf beiden Seiten konsistent verwenden.
|
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)
|
## 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
|
```js
|
||||||
interface SyncState {
|
// {
|
||||||
documentId: string;
|
// documentId, // UUID
|
||||||
serverVersion: number; // letzte bestätigte Server-Version
|
// serverVersion, // letzte bestätigte Server-Version
|
||||||
serverLines: string[]; // Stand der Server-Version (Schattenkopie)
|
// serverLines, // Stand der Server-Version (Schattenkopie)
|
||||||
localLines: string[]; // aktueller Editor-Inhalt
|
// localLines, // aktueller Editor-Inhalt
|
||||||
pending: Op[] | null; // gerade unterwegs befindlicher Patch
|
// pending, // gerade unterwegs befindlicher Patch (oder null)
|
||||||
status: "idle" | "sending" | "conflict" | "deleted" | "offline";
|
// 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`,
|
1. **Init:** `GET /documents/{id}` → `serverVersion`, `serverLines`,
|
||||||
`localLines` initialisieren; Feed-Schleife starten.
|
`localLines` initialisieren; Feed-Schleife starten.
|
||||||
2. **Lokale Eingabe:** Editor schreibt nur `localLines`. Ein Debounce
|
2. **Lokale Eingabe:** Der Editor schreibt nur `localLines`. Ein Debounce von
|
||||||
(Empfehlung: 1500 ms nach letztem Tastendruck, zusätzlich sofort bei
|
**1500 ms** nach dem letzten Tastendruck, zusätzlich sofort bei Blur und
|
||||||
Blur/Fenster-Verlassen via `visibilitychange`) triggert `flush()`.
|
`visibilitychange`, triggert `flush()`. Der Wert bleibt bewusst kurz: Die
|
||||||
3. **`flush()`:** wenn `pending` leer und `localLines ≠ serverLines`:
|
Rate-Limit-Disziplin galt einem fremden Etherpad-Server, am eigenen
|
||||||
`ops = computeOps(serverLines, localLines)`, PATCH senden,
|
Backend gelten eigene Grenzen (D76).
|
||||||
`pending = ops`, `status = "sending"`.
|
3. **`flush()`:** wenn kein `pending` und `localLines ≠ serverLines`:
|
||||||
- 200 → `serverVersion = antwort.version`,
|
`ops = computeOps(serverLines, localLines)`, Prüfsumme über
|
||||||
`serverLines = applyOps(serverLines, pending)`, `pending = null`.
|
`serverLines.join("\n")` bilden, PATCH senden, `pending = ops`,
|
||||||
Falls sich `localLines` inzwischen weiter geändert hat: erneut flushen.
|
`status = 'sending'`.
|
||||||
- 409 → Rebase (§4).
|
- **200** → `serverVersion = antwort.version`; erst
|
||||||
4. **Feed-Ereignis (200):** Remote-Ops einarbeiten (§5). Niemals während
|
`serverLines = applyOps(serverLines, antwort.opsSinceBase)` (falls
|
||||||
`status == "sending"` anwenden — Feed-Antworten bis zur PATCH-Antwort
|
vorhanden), dann `applyOps(…, pending)`; `pending = null`, `seq++`.
|
||||||
puffern (Queue), sonst entstehen Races zwischen eigener und fremder
|
Hat sich `localLines` inzwischen weiter geändert: erneut flushen.
|
||||||
Änderung.
|
- **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
|
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
|
Ein 409 heißt: Eure Änderungen betreffen **dieselben Zeilen**. Alles andere
|
||||||
`opsSinceBase` (fremd). Vorgehen:
|
hat der Server bereits rebased.
|
||||||
|
|
||||||
1. `theirs = opsSinceBase`, `mine = computeOps(serverLines, localLines)`.
|
1. `serverLines = applyOps(serverLines, opsSinceBase)`,
|
||||||
2. **Überlappungsprüfung:** berechne für jede Op ihren betroffenen
|
`serverVersion = currentVersion`.
|
||||||
Zeilenbereich in Basis-Koordinaten (`[index, index + count)` bzw. für
|
2. `status = 'conflict'`, Editieren weiter erlauben, aber nicht senden.
|
||||||
insert `[index, index]`). Überschneidet sich ein Bereich aus `mine` mit
|
3. Dialog mit genau zwei Möglichkeiten, jeweils **nur für die überlappenden
|
||||||
einem aus `theirs` → **echter Konflikt**: `status = "conflict"`, UI zeigt
|
Zeilen** — alles Übrige ist bereits sauber übernommen:
|
||||||
Dialog („Fremde Änderung übernehmen und meine verwerfen" / „Meine
|
- **Fremde Fassung übernehmen**: die eigenen überlappenden Ops verwerfen.
|
||||||
erzwingen" — letzteres = fremde Ops anwenden, eigene Zeilen darüber
|
- **Eigene Fassung durchsetzen**: die überlappenden Zeilen des neuen
|
||||||
schreiben, als neuen Patch senden). Keine automatische Silent-Merge-Magie
|
Serverstands durch die eigenen ersetzen und als neuen Patch senden.
|
||||||
bei Überlappung.
|
Dort gewinnt einer vollständig — das ist bewusst so, und nichts geht
|
||||||
3. **Kein Überlappen (Normalfall):**
|
endgültig verloren: Jede Version steht in der Server-Historie.
|
||||||
- `serverLines = applyOps(serverLines, theirs)`,
|
4. Keine automatische Zusammenführung bei Überlappung, keine Konfliktmarker
|
||||||
`serverVersion = currentVersion`.
|
im Text.
|
||||||
- `localLines`: ebenfalls `theirs` anwenden, aber mit Index-Verschiebung
|
|
||||||
durch die eigenen, noch nicht gesendeten Edits. Einfachste korrekte
|
|
||||||
Variante: `mine` gegen `theirs` verschieben (für jede eigene Op:
|
|
||||||
Summe der Zeilendelta aller fremden Ops mit kleinerem Index addieren),
|
|
||||||
dann `localLines = applyOps(serverLines, mineShifted)`.
|
|
||||||
- Danach normal `flush()`.
|
|
||||||
|
|
||||||
## 5. Remote-Ops anwenden ohne Cursor-Verlust
|
## 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
|
- Cursor (`selectionStart`/`selectionEnd`) in `(zeile, spalte)` umrechnen.
|
||||||
Editors übersetzen (CM6: `dispatch({changes: […]})` mit
|
- Pro Op: Bereich vollständig **unterhalb** der Cursor-Zeile → nichts;
|
||||||
from/to-Offsets; Monaco: `applyEdits`). Der Editor verschiebt Cursor,
|
vollständig **oberhalb** → Cursor-Zeile um das Zeilendelta der Op
|
||||||
Selektion und Scrollposition dann selbst korrekt. **Das ist der bevorzugte
|
verschieben; Op **trifft** die Cursor-Zeile → Zeile beibehalten (auf die
|
||||||
Weg — niemals `setValue()` mit dem Gesamttext aufrufen**, das ist genau
|
neue Zeilenanzahl klemmen), Spalte auf die neue Zeilenlänge klemmen.
|
||||||
der verbotene „Cursor springt an den Anfang"-Fall.
|
- Zurückrechnen, `setSelectionRange` setzen; Scrollposition **vorher sichern
|
||||||
- **Rohe Textarea (Fallback):** Cursor via `selectionStart` in
|
und wiederherstellen** (auch `scrollLeft` — das Textfeld bricht nicht um,
|
||||||
`(zeile, spalte)` umrechnen. Pro Op: Bereich komplett unterhalb der
|
D49).
|
||||||
Cursor-Zeile → nichts; komplett oberhalb → Cursor-Zeile um das
|
- Der Zeilennummern-Streifen und der Spiegel (D33) müssen danach neu
|
||||||
Zeilendelta der Op verschieben; Op trifft die Cursor-Zeile → Zeile
|
gemessen werden, ebenso das Diagramm.
|
||||||
beibehalten (ggf. auf neue Zeilenanzahl klemmen), Spalte auf neue
|
- **IME:** Während einer aktiven Composition (`compositionstart` bis
|
||||||
Zeilenlänge klemmen. Danach zurückrechnen und `setSelectionRange` setzen,
|
`compositionend`) keine Remote-Ops schreiben — puffern und danach anwenden.
|
||||||
Scrollposition vorher sichern und wiederherstellen.
|
- **Niemals** den Gesamttext neu setzen. Das ist genau der verbotene
|
||||||
- **IME:** während einer aktiven Composition (`compositionstart` bis
|
„Cursor springt an den Anfang"-Fall.
|
||||||
`compositionend`) keine Remote-Ops in den Editor schreiben — in einer
|
|
||||||
Queue puffern und danach anwenden.
|
|
||||||
|
|
||||||
## 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
|
- Kein WebSocket, kein SSE, kein setInterval-Kurztakt-Polling — nur die
|
||||||
Long-Poll-Schleife (Rate-Limit-Disziplin ist eine harte Anforderung).
|
Long-Poll-Schleife.
|
||||||
- Kein Vollreload des Dokuments außer im 422-Fall.
|
- Keine neue Laufzeit-Abhängigkeit (§0) — insbesondere kein jsdiff und kein
|
||||||
- Keine Ops auf Zwischenständen aufsetzen: `computeOps` immer gegen
|
Editor-Framework.
|
||||||
`serverLines` der bestätigten `serverVersion`.
|
- 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.
|
- 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;
|
- `applyOps`: jede Op-Art; Anhängen; letzte Zeile löschen; leeres Dokument;
|
||||||
mehrere Ops in einem Diff; Property-Test Roundtrip mit `computeOps`.
|
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;
|
- Cursor-Korrektur: Änderung oberhalb / unterhalb / auf der Cursor-Zeile;
|
||||||
„300-Zeilen-Dokument, Edit in Zeile 5, Cursor in Zeile 200 bleibt
|
„300-Zeilen-Dokument, Edit in Zeile 5, Cursor in Zeile 200 bleibt
|
||||||
inhaltlich an derselben Stelle".
|
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.
|
- Feed liefert Ops → Editorinhalt aktualisiert, Cursor stabil.
|
||||||
- PATCH → 409 mit nicht überlappenden `opsSinceBase` → automatischer
|
- PATCH mit veralteter Basis ohne Überlappung → 200 mit `opsSinceBase` →
|
||||||
Rebase + erneuter PATCH → 200.
|
Schattenkopie stimmt danach mit dem Server überein.
|
||||||
- PATCH → 409 mit überlappenden Ops → Konfliktdialog erscheint.
|
- PATCH → 409 mit überlappenden Ops → Konfliktdialog erscheint, beide Wege
|
||||||
- Feed meldet `DELETED` → Editor gesperrt, Restore-Flow funktioniert.
|
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.
|
- Long-Poll 204 → nahtloses Re-Polling; Netzwerkfehler → Backoff.
|
||||||
|
|
||||||
## 8. Reihenfolge
|
## 9. Reihenfolge
|
||||||
|
|
||||||
1. `diff.ts`: `applyOps`, `computeOps`, Bereichs-/Shift-Helfer + Unit-Tests
|
1. `frontend/src/diff.js`: `applyOps`, `computeOps`, Überlappungs- und
|
||||||
2. `syncEngine.ts`: Zustand, flush, Feed-Schleife, Rebase + Tests gegen Mock
|
Verschiebungs-Helfer, Cursor-Korrektur + Unit-Tests
|
||||||
3. Editor-Anbindung (Transaktions-Anwendung, Cursor, IME)
|
2. `frontend/src/sync.js`: Zustand, `flush`, Feed-Schleife, Idempotenz +
|
||||||
4. UI: Konfliktdialog, Deleted-Banner, Offline-Indikator
|
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
|
## Ziel und Rahmenbedingungen
|
||||||
|
|
||||||
@@ -8,10 +11,10 @@ Status: Entwurf zur Diskussion — noch nichts implementiert.
|
|||||||
ca. **10 Beobachter**, davon **2–3 gelegentliche Editoren**, praktisch nie
|
ca. **10 Beobachter**, davon **2–3 gelegentliche Editoren**, praktisch nie
|
||||||
in derselben Sekunde.
|
in derselben Sekunde.
|
||||||
- **Nur HTTP**, kein WebSocket. Wenige, sparsame Requests (Lehre aus den
|
- **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
|
- **Kein Neuladen des Dokuments** im Normalbetrieb: Clients erhalten
|
||||||
Zeilen-Diffs und wenden sie lokal an, damit Cursor/Scrollposition 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
|
- Das Werkbaum-Format ist **zeilenorientiert**; Zeilen-IDs (`#id`) sind
|
||||||
optional und identifizieren Knoten, nicht Zeilen. Das Protokoll arbeitet
|
optional und identifizieren Knoten, nicht Zeilen. Das Protokoll arbeitet
|
||||||
deshalb ausschließlich auf **physischen Zeilen** und braucht keine IDs.
|
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
|
entscheidet** (rebase, neu laden, verwerfen). Das Dokument darf nie
|
||||||
kaputtgehen.
|
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
|
## Grundidee in einem Satz
|
||||||
|
|
||||||
Jede Dokumentänderung ist ein **zeilenbasiertes Diff gegen eine
|
Jede Dokumentänderung ist ein **zeilenbasiertes Diff gegen eine
|
||||||
Basisversion**; der Server akzeptiert es nur, wenn die Basisversion noch die
|
Basisversion**; der Server wendet es an — notfalls auf eine neuere Version
|
||||||
aktuelle ist (Optimistic Locking auf Dokumentebene), und verteilt akzeptierte
|
verschoben — und verteilt das Ergebnis über **Long Polling** an alle
|
||||||
Diffs über **Long Polling** an alle Beobachter.
|
Beobachter.
|
||||||
|
|
||||||
## Datenmodell: das Zeilen-Diff
|
## Datenmodell: das Zeilen-Diff
|
||||||
|
|
||||||
@@ -35,6 +54,9 @@ Operationen sind nach Index aufsteigend sortiert und überlappen nicht.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"baseVersion": 41,
|
"baseVersion": 41,
|
||||||
|
"checksum": "sha256:9f2b…",
|
||||||
|
"clientId": "c-8a41…",
|
||||||
|
"seq": 17,
|
||||||
"ops": [
|
"ops": [
|
||||||
{ "op": "replace", "index": 12, "count": 1,
|
{ "op": "replace", "index": 12, "count": 1,
|
||||||
"lines": [" - [~] Backend (L) @ben"] },
|
"lines": [" - [~] Backend (L) @ben"] },
|
||||||
@@ -51,15 +73,38 @@ Operationen sind nach Index aufsteigend sortiert und überlappen nicht.
|
|||||||
- `delete`: `count` Zeilen ab `index` entfallen.
|
- `delete`: `count` Zeilen ab `index` entfallen.
|
||||||
|
|
||||||
Warum Indizes statt Inhalts-Hashes reichen: Der Server kennt die
|
Warum Indizes statt Inhalts-Hashes reichen: Der Server kennt die
|
||||||
Basisversion vollständig (Historie speichert Snapshots). Version + Index ist
|
Basisversion vollständig. Version + Index ist damit eindeutig — auch bei
|
||||||
damit eindeutig — auch bei identischen Zeilen (Leerzeilen!). Ein optionales
|
identischen Zeilen (Leerzeilen!).
|
||||||
`"checksum"`-Feld (Hash des Gesamtdokuments der Basisversion) dient nur als
|
|
||||||
Integritätsprüfung gegen Client-Bugs.
|
|
||||||
|
|
||||||
Werkbaum-Besonderheiten sind damit automatisch abgedeckt: Fortsetzungszeilen
|
**`checksum` ist Pflicht** (Hash des gesamten Basistexts). Die Versionsnummer
|
||||||
(` \`), `"`-Beschreibungszeilen und der `---`-Beschreibungsteil sind schlicht
|
bestätigt nur, dass die Basis dieselbe *Version* ist, nicht dass beide Seiten
|
||||||
physische Zeilen. Halbfertige Zwischenzustände rendert Werkbaum mit Warnung
|
sie *gleich lesen*; ein Index-Versatz zerstörte Text sonst unbemerkt. Passt
|
||||||
weiter — zeilenweise Updates sind hier risikoarm.
|
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)
|
## API-Erweiterung (OpenAPI-Spec)
|
||||||
|
|
||||||
@@ -67,11 +112,27 @@ weiter — zeilenweise Updates sind hier risikoarm.
|
|||||||
|
|
||||||
Request: das Diff-Objekt oben.
|
Request: das Diff-Objekt oben.
|
||||||
|
|
||||||
- **200 OK**: akzeptiert. Antwort: `{ "version": 42 }` (neue Version).
|
- **200 OK**: akzeptiert. Antwort:
|
||||||
Der Server wendet das Diff an, inkrementiert die Dokumentversion, schreibt
|
|
||||||
einen Historieneintrag (ChangeType `UPDATED`).
|
```json
|
||||||
- **409 Conflict**: `baseVersion` ist nicht mehr aktuell. Antwort enthält
|
{ "version": 42, "opsSinceBase": [ … ] }
|
||||||
alles, was der Client zum Weiterarbeiten braucht — **ohne Neuladen**:
|
```
|
||||||
|
|
||||||
|
`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
|
```json
|
||||||
{
|
{
|
||||||
@@ -80,60 +141,115 @@ Request: das Diff-Objekt oben.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Client entscheidet:
|
Der Client zeigt dann zwei Knöpfe: **fremde Fassung übernehmen** oder
|
||||||
- **Rebase**: fremde Ops lokal anwenden; überlappen sie nicht mit den
|
**eigene durchsetzen** — jeweils nur für die überlappenden Zeilen, alles
|
||||||
eigenen Änderungen, eigene Ops auf neue Indizes verschieben und erneut
|
übrige ist bereits rebased. An dieser Stelle gewinnt einer vollständig,
|
||||||
senden. Deckt den häufigsten Fall („jemand hat weiter oben editiert")
|
aber nichts geht endgültig verloren: Jede Version steht in der Historie.
|
||||||
ohne Nutzerinteraktion ab.
|
|
||||||
- **Konflikt anzeigen**: bei Überlappung Nutzer fragen (übernehmen /
|
|
||||||
verwerfen / manuell mergen).
|
|
||||||
- **404**: Dokument gelöscht (Restore-Hinweis in der Problem-Detail-Antwort).
|
|
||||||
- **422**: Diff nicht anwendbar (Index außerhalb, Checksum-Fehler) —
|
|
||||||
deutet auf einen Client-Bug, Client sollte neu laden.
|
|
||||||
|
|
||||||
`PUT /documents/{id}` bleibt als „Ganzdokument ersetzen" bestehen
|
- **404**: Dokument gelöscht (Restore-Hinweis in der Problem-Detail-Antwort).
|
||||||
(Import, Reparatur), wertet aber künftig `expectedVersion` aus.
|
- **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
|
### 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
|
```json
|
||||||
{
|
{
|
||||||
"fromVersion": 41,
|
"fromVersion": 41,
|
||||||
"currentVersion": 43,
|
"currentVersion": 43,
|
||||||
"ops": [ ...kumuliertes Diff 41 → 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
|
- **Ist `since` bereits verdichtet** (siehe „Zwei Ebenen" unten), kann der
|
||||||
(Empfehlung: 25 s, unterhalb üblicher Proxy-Timeouts). Kommt in der Zeit
|
Server kein exaktes Diff mehr liefern. Dann enthält die Antwort statt `ops`
|
||||||
eine Änderung, antwortet er sofort; sonst **204 No Content**, und der
|
den **Volltext**:
|
||||||
Client pollt erneut.
|
|
||||||
|
```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**,
|
- Latenz: praktisch sofort. Kosten: **1 offene HTTP-Anfrage pro Beobachter**,
|
||||||
~2,4 Requests/Minute im Leerlauf — rate-limit-freundlich, PWA-tauglich,
|
~2,4 Requests/Minute im Leerlauf — rate-limit-freundlich, PWA-tauglich,
|
||||||
kein WebSocket nötig.
|
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:
|
Client-Schleife eines Beobachters:
|
||||||
|
|
||||||
```
|
```
|
||||||
loop:
|
loop:
|
||||||
antwort = GET /changes?since=meineVersion&wait=25
|
antwort = GET /changes?since=meineVersion&wait=25
|
||||||
wenn 200: ops lokal anwenden, meineVersion = currentVersion,
|
wenn 200 mit ops: ops lokal anwenden, meineVersion = currentVersion
|
||||||
Cursor-Indizes um Verschiebungen oberhalb korrigieren
|
wenn 200 mit content: Stand ersetzen, meineVersion = currentVersion
|
||||||
wenn 204: weiter
|
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?
|
### Warum Long Polling und nicht SSE?
|
||||||
|
|
||||||
Server-Sent Events wären die Alternative (eine dauerhafte Verbindung,
|
Server-Sent Events wären die Alternative (eine dauerhafte Verbindung,
|
||||||
Push vom Server). Long Polling gewinnt hier, weil es (a) reines
|
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
|
(b) keinerlei Sonderbehandlung in Proxies/PWA-Service-Workern braucht und
|
||||||
(c) bei 10 Beobachtern der Effizienzunterschied irrelevant ist. Ein
|
(c) bei 10 Beobachtern der Effizienzunterschied irrelevant ist. Ein
|
||||||
späterer Umstieg auf SSE oder WebSocket ändert nur den Feed-Endpunkt;
|
späterer Umstieg auf SSE oder WebSocket ändert nur den Feed-Endpunkt;
|
||||||
@@ -141,50 +257,128 @@ Diff-Format und Konfliktlogik bleiben identisch.
|
|||||||
|
|
||||||
## Serverseitige Umsetzung
|
## Serverseitige Umsetzung
|
||||||
|
|
||||||
- **Diff anwenden**: Snapshot der Basisversion aus der Historie laden
|
- **Diff anwenden**: Snapshot der Basisversion laden (bzw. aktueller Stand,
|
||||||
(bzw. aktueller Stand, wenn `baseVersion == currentVersion`, der
|
wenn `baseVersion == currentVersion`, der Normalfall), Prüfsumme
|
||||||
Normalfall), Ops anwenden, als neue Version speichern.
|
vergleichen, Ops anwenden, als neue Version speichern.
|
||||||
- **Diff berechnen** (für 409-Antwort und Feed): Zeilen-Diff zwischen zwei
|
- **Diff berechnen** (für 409-Antwort und Feed): Zeilen-Diff zwischen zwei
|
||||||
Snapshots aus der Historie (Standard-Algorithmus, z. B. Myers über
|
Snapshots (Myers oder eine einfache LCS-Implementierung).
|
||||||
`java.util`-nahe Bibliothek oder eigene simple Implementierung).
|
- **Rebase** (siehe PATCH): Ops einer veralteten Basis gegen die
|
||||||
Alternativ können eingereichte Ops pro Version direkt mitgespeichert
|
zwischenzeitlichen verschieben, sofern sie sich nicht überschneiden.
|
||||||
werden — Optimierung, kein Muss für v1.
|
|
||||||
- **Long Polling**: Spring MVC `DeferredResult` + ein In-Process-Notifier
|
- **Long Polling**: Spring MVC `DeferredResult` + ein In-Process-Notifier
|
||||||
(pro Dokument eine Warteliste; `notifyAll` bei akzeptiertem Update).
|
(pro Dokument eine Warteliste; Zustellung bei akzeptiertem Update **und**
|
||||||
Kein zusätzliches Framework nötig.
|
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
|
- **Serialisierung**: Updates pro Dokument strikt sequenziell
|
||||||
(Locking pro Dokument-UUID), damit Versionsprüfung + Anwenden atomar sind.
|
(Locking pro Dokument-UUID), damit Prüfung, Rebase und Anwenden atomar sind.
|
||||||
- **Historie**: unverändert Snapshots; das Keyframe/Kompressions-Schema aus
|
|
||||||
der Speicher-Evaluation ist eine spätere, unabhängige Optimierung hinter
|
### Historie in zwei Ebenen
|
||||||
dem `DocumentHistoryRepository`-Interface.
|
|
||||||
|
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)
|
## Grenzen der simplen Variante (bewusst akzeptiert)
|
||||||
|
|
||||||
- Konflikterkennung auf **Dokumentebene**: Zwei Editoren, die gleichzeitig
|
- Konflikterkennung auf **Dokumentebene** mit serverseitigem Rebase: Zwei
|
||||||
verschiedene Stellen ändern, erzeugen formal einen Konflikt — der
|
Editoren an verschiedenen Stellen stören einander nicht mehr; ein 409 gibt
|
||||||
Rebase-Mechanismus in der 409-Antwort löst das aber in der Praxis
|
es nur bei echter Überlappung derselben Zeilen.
|
||||||
transparent. Erst wenn das nicht reicht, lohnt Konfliktprüfung pro
|
|
||||||
Zeilenbereich (die 409-Struktur bleibt dabei gleich).
|
|
||||||
- Kein Präsenz-Feature (wer ist online, fremde Cursor). Später über ein
|
- Kein Präsenz-Feature (wer ist online, fremde Cursor). Später über ein
|
||||||
leichtgewichtiges `presence`-Feld im Feed nachrüstbar; das `!!!`-Fokusmark
|
leichtgewichtiges `presence`-Feld im Feed nachrüstbar — `clientId` und
|
||||||
des Formats kann dafür genutzt werden.
|
Anzeigename gibt es dafür schon; das `!!!`-Fokusmark des Formats kann
|
||||||
|
ergänzend genutzt werden.
|
||||||
- Clientseitige Verschlüsselung: Das Protokoll transportiert Zeilen als
|
- Clientseitige Verschlüsselung: Das Protokoll transportiert Zeilen als
|
||||||
opake Strings und funktioniert unverändert mit Ciphertext pro Zeile —
|
opake Strings und funktioniert unverändert mit Ciphertext pro Zeile —
|
||||||
nur das serverseitige Diff-Berechnen entfiele dann (Clients müssten Ops
|
nur das serverseitige Diff-Berechnen und Rebasen entfiele dann (Clients
|
||||||
immer selbst liefern; die Struktur erlaubt das bereits).
|
müssten Ops immer selbst liefern; die Struktur erlaubt das bereits).
|
||||||
|
|
||||||
## Teststrategie
|
## Teststrategie
|
||||||
|
|
||||||
- **Cucumber**: „Client B sieht die Änderung von Client A im Feed",
|
- **Cucumber**: „Client B sieht die Änderung von Client A im Feed",
|
||||||
„Patch mit veralteter Basisversion liefert 409 mit opsSinceBase",
|
„veraltete Basis ohne Überlappung wird serverseitig rebased und akzeptiert",
|
||||||
„Feed meldet DELETED", „Rebase-Fall: nicht überlappende Änderung nach 409
|
„veraltete Basis mit Überlappung liefert 409 mit opsSinceBase",
|
||||||
erneut einreichen".
|
„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,
|
- **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)
|
1. Diff-Modell + Anwenden/Berechnen/Rebasen als reine Kotlin-Funktionen
|
||||||
2. `PATCH /content` inkl. 409-Antwort (Spec + Cucumber)
|
(Unit-Tests)
|
||||||
3. `GET /changes` mit Long Polling (Spec + Cucumber)
|
2. Historie in zwei Ebenen + gezielter Repository-Zugriff
|
||||||
4. Client-Anpassung (Feed-Schleife, lokales Anwenden, Rebase)
|
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.
|
||||||
|
|||||||
@@ -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
|
inkl. des per `:#…` gezogenen Ziels unter einer Zugabe; `data-sub-*` in
|
||||||
Dokumentreihenfolge, dedupliziert, nie an offenen Knoten, nie für
|
Dokumentreihenfolge, dedupliziert, nie an offenen Knoten, nie für
|
||||||
ausgeblendete verworfene).
|
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".
|
||||||
|
|||||||
Reference in New Issue
Block a user