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
+62 -18
View File
@@ -1,9 +1,10 @@
# Editor Backend Grundgerippe
CRUD-Skelett mit **Spring Boot 4**, **Kotlin** und **API First** (OpenAPI 3, YAML).
Die vier HTTP-Befehle (GET, POST, PUT, DELETE) sind für die Ressource `Document`
umgesetzt bewusst noch **ohne Autorisierung**, aber mit vorbereiteten
Erweiterungspunkten für Live-Editing, Autorisierung und clientseitige
CRUD mit **Spring Boot 4**, **Kotlin** und **API First** (OpenAPI 3, YAML) für
die Ressource `Document` (GET, POST, PUT, DELETE), dazu Historie,
Wiederherstellung und das Einreichen von Zeilen-Diffs fürs Live-Editing
(`PATCH …/content`) bewusst noch **ohne Autorisierung**, aber mit
vorbereiteten Erweiterungspunkten dafür und für clientseitige
Verschlüsselung.
## Voraussetzungen
@@ -34,13 +35,17 @@ Generierter Code wird nicht eingecheckt und zählt nicht zur Code Coverage.
## Teststrategie
- **Behavior-Tests (Cucumber, `src/test/resources/features/dokumente.feature`)**
testen die API von außen gegen die laufende Anwendung (`RANDOM_PORT`):
- **Behavior-Tests (Cucumber, `src/test/resources/features/`)** testen die API
von außen gegen die laufende Anwendung (`RANDOM_PORT`):
Statuscodes, Payloads, Fehlerpfade. Die Szenarien sind auf Deutsch
(`# language: de`) und dienen als lebende Dokumentation.
- **Unit-Tests (JUnit 5 + MockK)** decken die Geschäftslogik im
`DocumentService` isoliert ab (Versionierung, Zeitstempel per festem `Clock`,
Fehlerfälle).
- **Unit-Tests (JUnit 5 + MockK)** decken die Geschäftslogik isoliert ab:
`DocumentService` (Versionierung, Zeitstempel per festem `Clock`,
Meilenstein-Regeln), `LiveEditingService` (Rebasen, Konflikt, Idempotenz,
Grenzen) und `LineDiff` (Anwenden, Berechnen, Überschneidung, Prüfsumme).
- **Gegenprobe statt Zählerei:** Zu jeder Regel wird geprüft, dass ihre
Mutation genau die nach ihr benannten Zusicherungen fallen lässt. Ein Test,
von dem man das nicht geprüft hat, ist nur eine Behauptung.
- **Coverage**: JaCoCo, Verifikation mit mind. 80 % Line Coverage
(`jacocoTestCoverageVerification`, hängt an `check`).
@@ -48,11 +53,13 @@ Generierter Code wird nicht eingecheckt und zählt nicht zur Code Coverage.
```
api/ DocumentsController (implementiert generiertes Interface),
GlobalExceptionHandler (RFC 9457 ProblemDetail)
service/ DocumentService (Geschäftslogik, Versionierung), Clock-Bean
GlobalExceptionHandler (RFC 9457 ProblemDetail), Diff-Mapper
service/ DocumentService (Geschäftslogik, Versionierung),
LiveEditingService (Diffs einreichen), PatchLog, Clock-Bean
diff/ Zeilen-Diff: anwenden, berechnen, rebasen, Prüfsumme (Spring-frei)
repository/ DocumentRepository + DocumentHistoryRepository (Interfaces)
persistence/ JPA-Entities, Spring-Data-Repositories und Adapter (H2/Liquibase)
domain/ Document (internes Modell, getrennt vom API-Modell)
domain/ Document, ContentPatch (interne Modelle, getrennt vom API-Modell)
```
Das interne Domänenmodell ist bewusst vom generierten API-Modell getrennt
@@ -72,8 +79,8 @@ werden.
Löschen, Wiederherstellen, Rückfall), beim Vollersatz per `PUT` und
**nach einer Schreibpause**. Letzteres ohne Zeitgeber: Die nächste
Änderung stellt fest, dass eine Pause war, und befördert die Version
davor nachträglich. Der Knopfdruck aus dem Konzept ist derselbe
Schalter (`milestone = true`), sobald `PATCH /content` ihn durchreicht.
davor nachträglich. Auf Knopfdruck setzt `PATCH /content` denselben
Schalter (`milestone: true`).
- Stellschrauben: `werkbaum.live-editing.milestone-pause` (30 s) und
`sync-retention` (1 h).
- `GET /api/v1/documents/{uuid}/history` liefert die Meilensteine (älteste
@@ -87,6 +94,45 @@ werden.
antwortet der Server bei existierendem Dokument mit 409, eine bereits
verdichtete Zielversion mit 404.
## Live-Editing: Änderungen als Zeilen-Diff
`PATCH /api/v1/documents/{uuid}/content` nimmt eine Änderung als Diff gegen
eine Basisversion entgegen — Zeilen sind opake Strings, das Backend parst die
Notation nicht (D14).
```json
{ "baseVersion": 41, "checksum": "sha256:9f2b…",
"clientId": "c-8a41…", "seq": 17,
"ops": [ { "op": "replace", "index": 12, "count": 1,
"lines": [" - [~] Backend (L) @ben"] } ] }
```
- **Der Server rebased selbst.** Ist die Basis veraltet, überschneiden sich die
Operationen aber nicht mit den zwischenzeitlichen, verschiebt er sie und
antwortet mit 200; die fremden Operationen stehen in `opsSinceBase`, damit
der Client seine Schattenkopie nachzieht. Reines Ablehnen führte zu
Starvation — ein Client mit hoher Latenz käme bei fleißigen Mitschreibern
womöglich nie durch.
- **409** nur bei echter Überschneidung, mit `currentVersion` und dem Diff von
der eingereichten Basis dorthin — der Client entscheidet, ohne neu zu laden.
Eine Einfügung ist dabei ein Punkt **zwischen** den Zeilen: an den Rändern
eines fremden Blocks kein Konflikt, in seinem Inneren schon.
- **`checksum` ist Pflicht.** Die Versionsnummer bestätigt nur, dass die Basis
dieselbe *Version* ist, nicht dass beide Seiten sie *gleich lesen*. Passt sie
nicht: **422**, Client lädt einmal neu. Ebenso bei Index außerhalb, bereits
verdichteter Basis und veralteter `seq`.
- **`clientId` + `seq` machen den Aufruf wiederholbar.** Geht die Antwort
unterwegs verloren, liefert eine Wiederholung das Ergebnis von damals, statt
die Änderung ein zweites Mal anzuwenden.
- **400** bei Grenzüberschreitung (`max-ops`, `max-content-length`) und bei
`delete`/`replace` ohne `count` — als 0 gelesen täte die Operation
stillschweigend nichts.
- Ohne `milestone: true` entsteht eine **Sync-Version** (siehe Historie oben);
der Knopfdruck setzt das Feld.
Die Änderung eines Dokuments läuft strikt sequenziell (Sperre je UUID,
**außerhalb** der Transaktion — innen gäbe der Proxy sie vor dem Commit frei).
## Vorbereitete Erweiterungen
**Autorisierung**
@@ -97,10 +143,8 @@ werden.
(„Angenommen ich bin als … angemeldet").
**Live-Editing** (Konzept: `docs/live-editing-proposal.md`, Entscheidung: D76)
- **Gebaut:** das Zeilen-Diff als reine Funktionen (`de.werkbaum.diff`
Anwenden, Berechnen, Rebasen, Prüfsumme) und die zweistufige Historie.
- **Offen:** `PATCH /content` samt Rebase und Idempotenz, der Änderungsfeed per
Long Polling, Master-Passwort für `GET /documents`, Client-Anpassung.
- **Offen:** der Änderungsfeed per Long Polling (`GET /changes`),
Master-Passwort für `GET /documents`, Client-Anpassung.
- `DocumentUpdateRequest.expectedVersion` ist im Vertrag vorgesehen, wird aber
noch nicht ausgewertet.