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.
|
||||
|
||||
@@ -5744,3 +5744,196 @@ und 0 Kanten im exportierten SVG, an → 3/3; `depLinks` überlebt den Reload.
|
||||
inkl. des per `:#…` gezogenen Ziels unter einer Zugabe; `data-sub-*` in
|
||||
Dokumentreihenfolge, dedupliziert, nie an offenen Knoten, nie für
|
||||
ausgeblendete verworfene).
|
||||
|
||||
## D76 — Live-Editing über HTTP: die offenen Punkte der beiden Konzepte entschieden
|
||||
Zu `backend/docs/live-editing-proposal.md` und
|
||||
`backend/docs/client-live-editing-instructions.md` (beide Entwurf, nichts
|
||||
implementiert). Die Konzepte sind in ihrem Kern schlüssig — zeilenbasierte
|
||||
Diffs gegen eine Basisversion, Optimistic Locking auf Dokumentebene, Long
|
||||
Polling statt WebSocket. Offen waren die Ränder: Löschen und
|
||||
Wiederherstellen, Wiederholungen nach Netzwerkfehlern, das Verhältnis zum
|
||||
tatsächlichen Frontend — und die Frage, was im Werkbaum-Editor überhaupt
|
||||
eine Textänderung ist. Die Antworten, jeweils mit dem Grund:
|
||||
|
||||
**Faltung wird geteilt.** Nach D38-Nachtrag 2 schreibt jedes Klappen eine
|
||||
Faltmarke in den Text zurück; unter Live-Editing wird daraus ein PATCH, der
|
||||
bei allen ankommt. Erwogen war, sie bei geteilten Dokumenten wie bei Pads
|
||||
(D31) nur sitzungsweise zu überlagern. Entschieden ist das Gegenteil: Der
|
||||
Text bleibt die eine Quelle der Wahrheit (D14), und „was du siehst, steht
|
||||
geschrieben" gilt uneingeschränkt. Auch der Falt-Durchschalter (D75)
|
||||
bekommt **keine** Sonderbehandlung, obwohl ein Druck den ganzen Baum
|
||||
umbaut. Sollte sich das im Betrieb als störend erweisen, ist die ehrliche
|
||||
Antwort, Faltung insgesamt persönlich zu machen — nicht eine Ausnahme für
|
||||
einen einzelnen Knopf.
|
||||
|
||||
**Zugriff über die unerratbare UUID, wie ein Pad-Link.** Kein Login, kein
|
||||
Rechtemodell; das Protokoll bleibt davon unberührt, echte Authentifizierung
|
||||
kann später als Schicht davor. Das kollidierte mit dem vorhandenen
|
||||
`GET /documents`, das sämtliche Dokumente samt Inhalt auflistet und damit
|
||||
jede UUID auffindbar macht — der Schutz wäre hinfällig gewesen. Dieser
|
||||
Endpunkt verlangt deshalb ein **Master-Passwort**, dessen Hash serverseitig
|
||||
in einer Umgebungsvariable liegt; geprüft wird mit **Spring Security**
|
||||
(erste Abhängigkeit dieser Art, bewusst: sie ist zugleich der Platz für die
|
||||
spätere richtige Authentifizierung). Ein einzelnes Passwort auf einem
|
||||
offenen Endpunkt braucht eine Sperre nach Fehlversuchen, und die
|
||||
Übertragung setzt HTTPS voraus.
|
||||
|
||||
**Identität pseudonym: Client-ID plus selbstgewählter Anzeigename** — das
|
||||
Etherpad-Modell (D31). Ohne Anmeldung ist der Name nur eine Behauptung und
|
||||
darf nicht wie ein Nachweis aussehen; er trägt aber vier Dinge zugleich:
|
||||
Wiedererkennung beim Retry, „geändert von" in der Historie, eine
|
||||
deterministische Reihenfolge bei gleichzeitigen Einfügungen und spätere
|
||||
Präsenz.
|
||||
|
||||
**Der Feed arbeitet auf der Historie, nicht am Dokument.** `delete()`
|
||||
entfernt das Dokument und lässt nur den Tombstone stehen — ein Feed am
|
||||
Dokument müsste danach 404 liefern, ausgerechnet für das DELETED-Ereignis,
|
||||
das er zustellen soll. Solange es Historieneinträge zur UUID gibt,
|
||||
antwortet der Feed also; 404 nur bei gänzlich unbekannter UUID, dieselbe
|
||||
Regel wie `history()` sie schon anwendet. Wartende Long-Polls müssen beim
|
||||
Löschen zugestellt bekommen, bevor die Warteliste verworfen wird.
|
||||
|
||||
**Die Historie bekommt zwei Ebenen.** Mit 1,5 s Debounce wird sie sonst zum
|
||||
Transaktionslog: hunderte Volltext-Snapshots eines 40-kB-Dokuments je
|
||||
Sitzung. Getrennt werden kurzlebige **Sync-Versionen** (tragen das
|
||||
Protokoll) und nutzersichtbare **Meilensteine**. Letztere entstehen nach
|
||||
einer Schreibpause und auf Knopfdruck — dasselbe Muster wie die „Früheren
|
||||
Stände" im Editor (D54), erprobt und den Nutzern vertraut. Bei
|
||||
Server-Dokumenten zeigt der Verlaufs-Knopf künftig die **Server**-Meilensteine
|
||||
statt der lokalen Stände: gleiche Bedienung, bessere Quelle (geteilt,
|
||||
überlebt Geräte- und Browserwechsel). Lokale Stände wären dort sogar
|
||||
irreführend, weil „mein Stand von vorhin" fremde Änderungen enthält, die man
|
||||
nie gesehen hat.
|
||||
|
||||
**Nachzügler bekommen den Volltext.** Ist `since` bereits verdichtet, kann
|
||||
der Server kein exaktes Diff mehr liefern. Statt eines eigenen Fehlerpfads
|
||||
enthält die Feed-Antwort dann den kompletten Inhalt samt Version — ein
|
||||
Roundtrip und ein Zustand weniger im Client, und der Cursor-Erhalt ist über
|
||||
hunderte Versionen hinweg ohnehin nicht zu retten. Deckt zugleich den
|
||||
PWA-Fall nach längerer Offline-Zeit ab.
|
||||
|
||||
**Der Server rebased selbst; 409 nur bei echter Überlappung.** Das Proposal
|
||||
lehnte jeden veralteten PATCH ab. Das führt zu **Starvation**: Ein Client
|
||||
mit höherer Latenz kommt bei fleißigen Mitschreibern womöglich nie durch,
|
||||
weil jeder Versuch beim Eintreffen wieder veraltet ist — genau deswegen hat
|
||||
CodeMirror sein `rebaseUpdates` nachgerüstet. Überschneiden sich die Ops
|
||||
nicht mit den zwischenzeitlichen, verschiebt der Server sie also selbst und
|
||||
antwortet mit 200; die Antwort liefert die fremden Ops mit, damit der Client
|
||||
seine Schattenkopie nachzieht. Nebengewinn: Der aufwendigste Teil der
|
||||
Client-Instruktion (§4, Rebase) wird nur noch für echte Konflikte gebraucht.
|
||||
|
||||
**Wiederholte Patches werden erkannt.** Geht die Antwort verloren — im
|
||||
Mobilnetz der Normalfall —, weiß der Client nicht, ob seine Änderung ankam;
|
||||
ein Retry würde sie über den Rebase ein zweites Mal anwenden. Der PATCH
|
||||
trägt deshalb Client-ID und eine **laufende Nummer**, und der Server
|
||||
beantwortet eine Wiederholung mit dem Ergebnis von damals.
|
||||
|
||||
**Einfügungen kollidieren untereinander nicht, mit Löschungen schon.** Die
|
||||
Client-Instruktion definierte den Bereich einer Op halboffen als
|
||||
`[index, index+count)`, für insert aber `[index, index]` — das ist die leere
|
||||
Menge und schneidet nichts, Einfüge-Konflikte wären nie erkannt worden. Zwei
|
||||
Einfügungen an derselben Stelle sind kein Konflikt: Beide Zeilen bleiben, die
|
||||
bereits bestätigte fremde steht oben. Eine Einfügung in einen Bereich, den
|
||||
ein anderer löscht, ist einer.
|
||||
|
||||
**Rollback bekommt einen eigenen Änderungstyp.** `restore()` tut zweierlei —
|
||||
ein gelöschtes Dokument wiederherstellen und ein lebendes auf eine alte
|
||||
Version zurücksetzen —, beides bisher als `RESTORED`. Der Client soll bei
|
||||
`RESTORED` die Sperre aufheben; beim Rollback gab es nie eine Sperre, dort
|
||||
ändert sich nur der Inhalt. Ein Typ, der zwei Dinge bedeutet, ist die
|
||||
Unschärfe, aus der später Fehler werden.
|
||||
|
||||
**Der Titel läuft mit.** Er ist ein Metadatum, kein Zeileninhalt, und bekommt
|
||||
einen eigenen Weg mit Versionsprüfung; das Feed-Ereignis führt den neuen
|
||||
Titel im Klartext mit. Bisher hätte ein Umbenennen eine Version erzeugt, von
|
||||
der niemand etwas erfährt.
|
||||
|
||||
**Prüfsumme als Pflichtfeld.** Das Proposal sah sie optional vor, die
|
||||
Client-Instruktion kannte sie gar nicht. Optional ist die schlechteste
|
||||
Variante — die Kosten der Spezifikation ohne den Nutzen. Die Versionsnummer
|
||||
bestätigt nur, dass die Basis dieselbe Version ist, nicht dass beide Seiten
|
||||
sie gleich lesen; ein Index-Versatz zerstört Text sonst unbemerkt, und dieses
|
||||
Projekt zieht durchweg den lauten Fehler dem stillen vor (D59, SPEC §4).
|
||||
Damit beide dasselbe hashen, normalisiert der **Server** Zeilenenden beim
|
||||
Speichern autoritativ auf LF (SPEC §12), der Client beim Laden ebenfalls.
|
||||
|
||||
**Der Client bleibt bei der Textarea.** Die Instruktion nennt CodeMirror 6
|
||||
oder Monaco als bevorzugten Weg und die Textarea als Fallback — bei Werkbaum
|
||||
ist sie der einzige Fall (D49), und `dependencies` ist leer. Gemessen kostet
|
||||
CodeMirror **120 kB gzip** für den Einstieg (Editor, Zeilennummern, Undo),
|
||||
voll ausgestattet 138 kB, gegen aktuell 294 kB Bundle. Bemerkenswert: Der
|
||||
Einstieg ist teuer, alles Weitere danach fast umsonst — Syntax-Hervorhebung
|
||||
und Autovervollständigung kosten zusammen 18 kB. Es gäbe echte Gewinne (die
|
||||
`execCommand`-Altlast aus D53/D55 verschwände, Zeilennummern und
|
||||
Autovervollständigung wären eingebaut, der Spiegel-Div entfiele), aber der
|
||||
Umbau berührt ein Dutzend Entscheidungen und darf nicht die Nebenwirkung
|
||||
eines anderen Features sein. CodeMirror bleibt eine **eigene Frage**.
|
||||
`@codemirror/collab` hilft dabei ohnehin nicht: Es arbeitet mit
|
||||
CodeMirror-ChangeSets und rebased **serverseitig**, setzt also JavaScript auf
|
||||
dem Server voraus.
|
||||
|
||||
**Die Diff-Berechnung wird selbst implementiert**, nicht per jsdiff — rund
|
||||
hundert Zeilen über die längste gemeinsame Teilfolge, in derselben
|
||||
Größenordnung wie der Zeilenumbruch aus D64. Dieselbe Grenze wie bei
|
||||
CodeMirror: keine Laufzeit-Abhängigkeit (D11/D19/D20).
|
||||
|
||||
**Adressiert wird über einen URL-Parameter**, wie `?sourceUrl=` (D23) und
|
||||
`?etherpad=` (D31): Die Identität leitet sich aus der URL ab, derselbe Link
|
||||
führt immer in dasselbe Dokument, der Name ist die URL. Angelegt wird über
|
||||
einen Menüeintrag „Auf den Server legen", analog zu „Als Datei speichern"
|
||||
(D72). Kein neues Bedienkonzept, und ein geteiltes Dokument wird ohnehin per
|
||||
Link geteilt.
|
||||
|
||||
**Debounce bleibt bei 1,5 s.** Die Rate-Limit-Disziplin des Proposals stammt
|
||||
von Etherpad — einem fremden Server mit 10 Abrufen je 90 s (D31). Am eigenen
|
||||
Backend gelten die eigenen Grenzen, und die Last ist gering: ein Request alle
|
||||
1,5 s ausschließlich während aktiven Tippens. Kleine Diffs und seltene
|
||||
Überschneidungen sind das wert.
|
||||
|
||||
**Im Konfliktfall zwei klar benannte Knöpfe** — fremde Fassung übernehmen
|
||||
oder eigene durchsetzen, jeweils nur für die überlappenden Zeilen; alles
|
||||
übrige wird ohnehin rebased. Einer gewinnt dort vollständig, aber nichts geht
|
||||
endgültig verloren: Jede Version steht in der Historie.
|
||||
|
||||
**Der Betrieb wird gemessen, bevor er festgeschrieben wird.** Long Polling
|
||||
hält je Beobachter eine Verbindung offen; hinter dem Apache der stabilen
|
||||
Instanz (D43) sind `ProxyTimeout`, Pufferung und das Worker-Modell offene
|
||||
Fragen, ebenso das Browser-Limit von sechs Verbindungen je Herkunft bei
|
||||
mehreren Tabs. Der `wait`-Wert von 25 s steht erst fest, wenn die
|
||||
Zielumgebung vermessen ist — die Lehre aus D17-Nachtrag 4: Was die Umgebung
|
||||
stellt, stellt der Emulator nicht.
|
||||
|
||||
**Ohne eigene Entscheidung festgehalten**, weil alternativlos: Gepufferte
|
||||
Feed-Antworten werden nur angewendet, wenn ihr `fromVersion` zur aktuellen
|
||||
Schattenkopie passt — sonst wendet ein Client dieselben Ops doppelt an, wenn
|
||||
Feed und 409-Antwort sie beide liefern. Der In-Process-Notifier setzt eine
|
||||
**Einzelinstanz** voraus. Der Feed braucht `Cache-Control: no-store` und eine
|
||||
serverseitige Obergrenze für `wait`. PATCH braucht ein Größenlimit für
|
||||
Dokument und Op-Anzahl. `DocumentHistoryRepository` braucht gezielten Zugriff
|
||||
auf eine einzelne Version, statt wie heute stets alle Einträge zu laden. Und
|
||||
die im Proposal zitierte „Speicher-Evaluation" existiert im Repo nicht — sie
|
||||
gehört nachgeliefert oder der Verweis aufgelöst.
|
||||
|
||||
**Nachtrag — die Zielumgebung ist vermessen (2026-08-26).** Gemessen auf
|
||||
`mih00.hostsharing.net`: Apache 2.4.68 mit **MPM event** und
|
||||
MaxRequestWorkers **1024**, `Timeout 300` und kein gesetztes `ProxyTimeout`,
|
||||
mod_proxy_http und mod_rewrite geladen, systemd-`Linger=yes` (ein eigener
|
||||
Dienst darf also dauerhaft laufen), PostgreSQL auf 5432 vorhanden.
|
||||
**Long Polling trägt dort**: Die Zeitgrenzen sind großzügig, zehn offene
|
||||
Verbindungen sind bei 1024 Workern unkritisch, und die Sorge vor einem
|
||||
Prozess je Verbindung war unbegründet — sie gilt mpm_prefork, hier läuft
|
||||
event. Auch Pufferung ist kein Thema: Anders als bei SSE kommt genau eine
|
||||
Antwort am Ende des Wartens.
|
||||
|
||||
Drei Befunde bleiben als Arbeit stehen. **Kein HTTP/2** — damit gilt das
|
||||
Browser-Limit von sechs Verbindungen je Herkunft, und mehrere Tabs derselben
|
||||
Person binden je eine dauerhaft; zu beheben serverseitig oder clientseitig
|
||||
über einen SharedWorker, der allen Tabs eine gemeinsame Feed-Verbindung gibt.
|
||||
**Nur Java 17** installiert, während `build.gradle.kts`
|
||||
`JavaLanguageVersion.of(21)` verlangt. Und der **Weg vom Apache zum Backend
|
||||
ist ungeklärt**: `ProxyPass` ist in `.htaccess` nicht zulässig,
|
||||
`~/doms/<domain>/etc/` ist leer; bliebe `RewriteRule … [P]` oder eine
|
||||
Rückfrage beim Hoster. Dieser letzte Punkt ist bewusst **nicht** getestet —
|
||||
dafür hätte eine Proxy-Regel in der Produktionsumgebung eingerichtet werden
|
||||
müssen. Details in `backend/docs/live-editing-proposal.md`, Abschnitt
|
||||
„Betrieb".
|
||||
|
||||
Reference in New Issue
Block a user