feat(backend): PATCH /content — Diffs einreichen, rebasen, wiederholen (Schritt 3)

Der Server rebased selbst: Ist die Basis veraltet, ueberschneiden sich die
Operationen aber nicht mit den zwischenzeitlichen, verschiebt er sie und
akzeptiert. Reines Ablehnen fuehrte zu Starvation — ein Client mit hoher
Latenz kaeme bei fleissigen Mitschreibern womoeglich nie durch. 409 gibt es
nur bei echter Ueberschneidung, mit allem, was der Client zum Weiterarbeiten
braucht, ohne neu zu laden.

Pruefsumme ist Pflicht (422 bei Abweichung): Die Versionsnummer bestaetigt
nur, dass die Basis dieselbe Version ist, nicht dass beide Seiten sie gleich
lesen. clientId + seq machen den Aufruf wiederholbar — im Mobilnetz ist die
verlorene Antwort der Normalfall.

Die Sperre je Dokument liegt ausserhalb der Transaktion: innen gaebe der
Proxy sie vor dem Commit frei, und der naechste Schreiber laese einen Stand,
der noch nicht steht. Deshalb ist LiveEditingService nicht transaktional und
schreibt ueber DocumentService.

Was das Konzept offenliess, ist jetzt entschieden und in D76-Nachtrag 4
begruendet: die Randfaelle der Einfuege-Ueberschneidung, die Trennung von
400 und 422, die gedeckelte Idempotenz im Speicher.

104 Tests, davon 8 Cucumber-Szenarien fuer das Live-Editing. Gegenprobe:
Pruefsumme nicht geprueft, Idempotenz entfernt, veraltete Basis abgelehnt
statt verschoben -> es fallen jeweils genau die danach benannten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-26 17:05:14 +02:00
co-authored by Claude Opus 5
parent d5cdff6058
commit ae85503b79
20 changed files with 1321 additions and 35 deletions
+24 -11
View File
@@ -1,9 +1,12 @@
# Live-Editing über HTTP (Variante „Simpel")
Status: **Konzept entschieden** (D76), noch nichts implementiert. Die offenen
Status: **Konzept entschieden** (D76), **Schritte 13 der Umsetzungsreihenfolge
gebaut** (Zeilen-Diff, zweistufige Historie, `PATCH /content`); der
Änderungsfeed, das Master-Passwort und der Client stehen aus. 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.
verwiesen. Was beim Bauen zusätzlich zu entscheiden war, steht dort in
Nachtrag 4.
## Ziel und Rahmenbedingungen
@@ -147,18 +150,25 @@ Request: das Diff-Objekt oben.
aber nichts geht endgültig verloren: Jede Version steht in der Historie.
- **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.
- **422**: Diff nicht anwendbar (Index außerhalb, **Prüfsummenfehler**,
Basisversion bereits verdichtet oder aus der Zukunft, veraltete `seq`) —
Client lädt einmalig neu. Der verdichtete Fall ist kein Client-Bug, aber
das Mittel ist dasselbe; ein geratenes Diff wäre schlechter.
**Ü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:
sich mit nichts und Einfüge-Konflikte blieben unerkannt. Der Punkt liegt
**zwischen** den Zeilen und kollidiert nur mit dem **Inneren** eines fremden
Bereichs (`start < index < end`). 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.
- An den **Rändern** ist sie dagegen keiner: vor bzw. hinter dem fremden Block
ist die Stelle eindeutig. Das ist der häufige Fall — wer eine Zeile über
einer gerade geänderten einfügt, bekommt keinen 409.
**Titel:** `PATCH /documents/{id}/title` (mit `expectedVersion`) ändert den
Titel; er ist ein Metadatum, kein Zeileninhalt. `PUT /documents/{id}` bleibt
@@ -167,7 +177,10 @@ als „Ganzdokument ersetzen" bestehen (Import, Reparatur) und wertet künftig
**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).
versehentlich durch einen Client-Bug); Überschreitung und ein `delete`/
`replace` ohne `count` sind **400**. Stellschrauben:
`werkbaum.live-editing.max-ops` (1000) und `max-content-length` (2 Mio.
Zeichen; der mitgelieferte Plan hat ~40 000).
### 2. `GET /documents/{id}/changes?since={version}&wait={seconds}` — Änderungsfeed
@@ -399,11 +412,11 @@ verworfen.
## Umsetzungsreihenfolge
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)
1. ~~Diff-Modell + Anwenden/Berechnen/Rebasen als reine Kotlin-Funktionen
(Unit-Tests)~~ — gebaut, `de.werkbaum.diff`
2. ~~Historie in zwei Ebenen + gezielter Repository-Zugriff~~ — gebaut
3. ~~`PATCH /content` inkl. Rebase, Idempotenz, Prüfsumme und 409 (Spec +
Cucumber)~~ — gebaut, `LiveEditingService`
4. `GET /changes` mit Long Polling, Volltext-Fall und Ereignistypen
(Spec + Cucumber)
5. Master-Passwort für `GET /documents` (Spring Security)