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:
co-authored by
Claude Opus 5
parent
d5cdff6058
commit
ae85503b79
+4
-3
@@ -7,9 +7,10 @@ Status-Sync), später Tenzu-Adapter.
|
||||
**Stand:** Gerüst steht — Dokumenten-CRUD mit Historie und Wiederherstellung,
|
||||
API-First aus `src/main/resources/openapi/api.yaml`, H2 mit Liquibase.
|
||||
Kommandos in README.md hier. Live-Editing (D76,
|
||||
`docs/live-editing-proposal.md`) ist in Arbeit: Schritte 1 und 2 der
|
||||
Reihenfolge dort sind gebaut (Zeilen-Diff in `de.werkbaum.diff`, Historie in
|
||||
zwei Ebenen), ab Schritt 3 (`PATCH /content`) steht es aus.
|
||||
`docs/live-editing-proposal.md`) ist in Arbeit: Schritte 1–3 der Reihenfolge
|
||||
dort sind gebaut (Zeilen-Diff in `de.werkbaum.diff`, Historie in zwei Ebenen,
|
||||
`PATCH /content` im `LiveEditingService`), ab Schritt 4 (`GET /changes` per
|
||||
Long Polling) steht es aus.
|
||||
|
||||
## Konventionen
|
||||
- Kotlin, **Spring Boot 4**, Gradle (Kotlin DSL), JDK 21.
|
||||
|
||||
+62
-18
@@ -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.
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
# Live-Editing über HTTP (Variante „Simpel")
|
||||
|
||||
Status: **Konzept entschieden** (D76), noch nichts implementiert. Die offenen
|
||||
Status: **Konzept entschieden** (D76), **Schritte 1–3 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)
|
||||
|
||||
@@ -2,13 +2,18 @@ package de.werkbaum.api
|
||||
|
||||
import de.werkbaum.generated.api.DocumentsApi
|
||||
import de.werkbaum.generated.model.Document as ApiDocument
|
||||
import de.werkbaum.generated.model.ContentPatchRequest
|
||||
import de.werkbaum.generated.model.ContentPatchResult
|
||||
import de.werkbaum.generated.model.DocumentCreateRequest
|
||||
import de.werkbaum.generated.model.DocumentHistoryEntry as ApiHistoryEntry
|
||||
import de.werkbaum.generated.model.DocumentUpdateRequest
|
||||
import de.werkbaum.generated.model.RestoreRequest
|
||||
import de.werkbaum.domain.ChangeAuthor
|
||||
import de.werkbaum.domain.ContentPatch
|
||||
import de.werkbaum.domain.Document
|
||||
import de.werkbaum.domain.DocumentHistoryEntry
|
||||
import de.werkbaum.service.DocumentService
|
||||
import de.werkbaum.service.LiveEditingService
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
@@ -24,6 +29,7 @@ import java.util.UUID
|
||||
@RequestMapping("/api/v1")
|
||||
class DocumentsController(
|
||||
private val service: DocumentService,
|
||||
private val liveEditing: LiveEditingService,
|
||||
) : DocumentsApi {
|
||||
|
||||
override fun listDocuments(): ResponseEntity<List<ApiDocument>> =
|
||||
@@ -57,6 +63,32 @@ class DocumentsController(
|
||||
return ResponseEntity.noContent().build()
|
||||
}
|
||||
|
||||
override fun patchDocumentContent(
|
||||
documentId: UUID,
|
||||
contentPatchRequest: ContentPatchRequest,
|
||||
): ResponseEntity<ContentPatchResult> {
|
||||
val outcome = liveEditing.patchContent(
|
||||
documentId,
|
||||
ContentPatch(
|
||||
baseVersion = contentPatchRequest.baseVersion,
|
||||
checksum = contentPatchRequest.checksum,
|
||||
author = ChangeAuthor(
|
||||
clientId = contentPatchRequest.clientId,
|
||||
displayName = contentPatchRequest.displayName,
|
||||
),
|
||||
seq = contentPatchRequest.seq,
|
||||
ops = contentPatchRequest.ops.map { it.toDomain() },
|
||||
milestone = contentPatchRequest.milestone ?: false,
|
||||
),
|
||||
)
|
||||
return ResponseEntity.ok(
|
||||
ContentPatchResult(
|
||||
version = outcome.version,
|
||||
opsSinceBase = outcome.opsSinceBase.toApi(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun getDocumentHistory(documentId: UUID): ResponseEntity<List<ApiHistoryEntry>> =
|
||||
ResponseEntity.ok(service.history(documentId).map { it.toApi() })
|
||||
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
package de.werkbaum.api
|
||||
|
||||
import de.werkbaum.diff.DiffNotApplicableException
|
||||
import de.werkbaum.generated.model.ContentConflict
|
||||
import de.werkbaum.service.ContentConflictException
|
||||
import de.werkbaum.service.DocumentConflictException
|
||||
import de.werkbaum.service.DocumentDeletedException
|
||||
import de.werkbaum.service.DocumentNotFoundException
|
||||
import de.werkbaum.service.InvalidPatchException
|
||||
import de.werkbaum.service.StalePatchSequenceException
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ProblemDetail
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||
|
||||
/**
|
||||
* Zentrale Fehlerbehandlung im Problem-Details-Format (RFC 9457),
|
||||
* passend zum ProblemDetail-Schema der OpenAPI-Spezifikation.
|
||||
*
|
||||
* Eine Ausnahme davon ist der Überschneidungs-Konflikt des Live-Editings: Er
|
||||
* ist kein bloßer Fehlertext, sondern trägt die Daten mit, die der Client zum
|
||||
* Weiterarbeiten braucht – und hat deshalb ein eigenes Schema.
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
class GlobalExceptionHandler {
|
||||
@@ -20,9 +31,48 @@ class GlobalExceptionHandler {
|
||||
title = "Dokument nicht gefunden"
|
||||
}
|
||||
|
||||
@ExceptionHandler(DocumentDeletedException::class)
|
||||
fun handleDeleted(ex: DocumentDeletedException): ProblemDetail =
|
||||
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.message ?: "Gelöscht").apply {
|
||||
title = "Dokument gelöscht"
|
||||
}
|
||||
|
||||
@ExceptionHandler(DocumentConflictException::class)
|
||||
fun handleConflict(ex: DocumentConflictException): ProblemDetail =
|
||||
ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.message ?: "Konflikt").apply {
|
||||
title = "Konflikt"
|
||||
}
|
||||
|
||||
/**
|
||||
* Echte Überschneidung: 409 mit aktueller Version und dem Diff von der
|
||||
* eingereichten Basis dorthin – damit der Client entscheiden kann, ohne
|
||||
* neu zu laden.
|
||||
*/
|
||||
@ExceptionHandler(ContentConflictException::class)
|
||||
fun handleContentConflict(ex: ContentConflictException): ResponseEntity<ContentConflict> =
|
||||
ResponseEntity.status(HttpStatus.CONFLICT).body(
|
||||
ContentConflict(
|
||||
currentVersion = ex.currentVersion,
|
||||
opsSinceBase = ex.opsSinceBase.toApi(),
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* Nicht anwendbar (422): Prüfsumme, Index oder eine Basisversion, die es
|
||||
* nicht mehr gibt. Der Client lädt einmalig neu – das ist der Preis
|
||||
* dafür, dass der Text nie kaputtgeht.
|
||||
*/
|
||||
@ExceptionHandler(DiffNotApplicableException::class, StalePatchSequenceException::class)
|
||||
fun handleNotApplicable(ex: RuntimeException): ProblemDetail =
|
||||
ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
ex.message ?: "Diff nicht anwendbar",
|
||||
).apply { title = "Diff nicht anwendbar" }
|
||||
|
||||
@ExceptionHandler(InvalidPatchException::class)
|
||||
fun handleInvalidPatch(ex: InvalidPatchException): ProblemDetail =
|
||||
ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
ex.message ?: "Ungültige Anfrage",
|
||||
).apply { title = "Ungültige Anfrage" }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.werkbaum.api
|
||||
|
||||
import de.werkbaum.diff.LineOp
|
||||
import de.werkbaum.generated.model.LineOperation
|
||||
import de.werkbaum.service.InvalidPatchException
|
||||
|
||||
/**
|
||||
* Übersetzt zwischen dem generierten API-Modell und dem internen Diff-Modell.
|
||||
*
|
||||
* Das API-Modell hat ein Feld je Form (`count`, `lines`, beide optional), das
|
||||
* interne ist eine versiegelte Hierarchie – dort kann eine Operation gar nicht
|
||||
* erst halb ausgefüllt sein. Genau dafür ist die Trennung da.
|
||||
*/
|
||||
internal fun LineOperation.toDomain(): LineOp = when (op) {
|
||||
LineOperation.Op.INSERT -> LineOp.Insert(index, lines.orEmpty())
|
||||
LineOperation.Op.DELETE -> LineOp.Delete(index, requiredCount())
|
||||
LineOperation.Op.REPLACE -> LineOp.Replace(index, requiredCount(), lines.orEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein fehlendes `count` bei `delete`/`replace` wird **nicht** als 0 gelesen:
|
||||
* Die Operation täte dann stillschweigend nichts bzw. würde zur Einfügung.
|
||||
* Lieber der laute Fehler (dieselbe Haltung wie in SPEC §4).
|
||||
*/
|
||||
private fun LineOperation.requiredCount(): Int =
|
||||
count ?: throw InvalidPatchException(
|
||||
"Operation '${op.value}' bei Index $index ohne 'count'"
|
||||
)
|
||||
|
||||
internal fun LineOp.toApi(): LineOperation = when (this) {
|
||||
is LineOp.Insert -> LineOperation(LineOperation.Op.INSERT, index, lines = lines)
|
||||
is LineOp.Delete -> LineOperation(LineOperation.Op.DELETE, index, count = count)
|
||||
is LineOp.Replace ->
|
||||
LineOperation(LineOperation.Op.REPLACE, index, count = count, lines = lines)
|
||||
}
|
||||
|
||||
internal fun List<LineOp>.toApi(): List<LineOperation> = map { it.toApi() }
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.werkbaum.domain
|
||||
|
||||
import de.werkbaum.diff.LineOp
|
||||
|
||||
/**
|
||||
* Wer eine Änderung eingereicht hat. Pseudonym: [clientId] ist eine zufällige
|
||||
* Kennung, [displayName] ein selbstgewählter Name.
|
||||
*
|
||||
* Ohne Anmeldung ist der Name eine **Behauptung** und darf in der Oberfläche
|
||||
* nicht wie ein Nachweis aussehen (D76, Etherpad-Modell). Er trägt trotzdem
|
||||
* vier Dinge: Wiedererkennung beim Retry, „geändert von" in der Historie, die
|
||||
* Reihenfolge bei gleichzeitigen Einfügungen und später die Präsenz.
|
||||
*/
|
||||
data class ChangeAuthor(
|
||||
val clientId: String,
|
||||
val displayName: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Eine eingereichte Änderung: ein Zeilen-Diff gegen [baseVersion].
|
||||
*
|
||||
* [checksum] ist Pflicht – die Versionsnummer bestätigt nur, dass die Basis
|
||||
* dieselbe *Version* ist, nicht dass beide Seiten sie *gleich lesen*.
|
||||
* [clientId] und [seq] machen das Einreichen wiederholbar: Geht die Antwort
|
||||
* unterwegs verloren, weiß der Client nicht, ob seine Änderung ankam.
|
||||
*/
|
||||
data class ContentPatch(
|
||||
val baseVersion: Long,
|
||||
val checksum: String,
|
||||
val author: ChangeAuthor,
|
||||
val seq: Long,
|
||||
val ops: List<LineOp>,
|
||||
val milestone: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Ergebnis einer angenommenen Änderung.
|
||||
*
|
||||
* [opsSinceBase] ist leer, wenn die Basis noch aktuell war; sonst stehen dort
|
||||
* die fremden Operationen, um die der Server verschoben hat – damit der Client
|
||||
* seine Schattenkopie nachzieht, ohne neu zu laden.
|
||||
*/
|
||||
data class ContentPatchOutcome(
|
||||
val version: Long,
|
||||
val opsSinceBase: List<LineOp>,
|
||||
)
|
||||
@@ -39,4 +39,6 @@ data class DocumentHistoryEntry(
|
||||
val changeType: ChangeType,
|
||||
val timestamp: OffsetDateTime,
|
||||
val milestone: Boolean = true,
|
||||
/** Wer die Änderung eingereicht hat – „geändert von" (D76); `null` bei den Wegen ohne Identität. */
|
||||
val author: ChangeAuthor? = null,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.werkbaum.persistence
|
||||
|
||||
import de.werkbaum.domain.ChangeAuthor
|
||||
import de.werkbaum.domain.ChangeType
|
||||
import de.werkbaum.domain.DocumentHistoryEntry
|
||||
import jakarta.persistence.Column
|
||||
@@ -42,6 +43,12 @@ class DocumentHistoryEntity(
|
||||
/** Meilenstein (nutzersichtbar, bleibt) oder Sync-Version (wird verdichtet) – D76. */
|
||||
@Column(nullable = false)
|
||||
var milestone: Boolean = true,
|
||||
|
||||
@Column(name = "client_id", length = 64)
|
||||
val clientId: String? = null,
|
||||
|
||||
@Column(name = "display_name", length = 64)
|
||||
val displayName: String? = null,
|
||||
) {
|
||||
fun toDomain() = DocumentHistoryEntry(
|
||||
documentId = documentId,
|
||||
@@ -51,6 +58,7 @@ class DocumentHistoryEntity(
|
||||
changeType = changeType,
|
||||
timestamp = changeTime,
|
||||
milestone = milestone,
|
||||
author = clientId?.let { ChangeAuthor(it, displayName) },
|
||||
)
|
||||
|
||||
companion object {
|
||||
@@ -62,6 +70,8 @@ class DocumentHistoryEntity(
|
||||
changeType = entry.changeType,
|
||||
changeTime = entry.timestamp,
|
||||
milestone = entry.milestone,
|
||||
clientId = entry.author?.clientId,
|
||||
displayName = entry.author?.displayName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.werkbaum.service
|
||||
|
||||
import de.werkbaum.diff.LineOp
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Echte Überschneidung mit einer zwischenzeitlichen Änderung (409).
|
||||
*
|
||||
* Trägt alles mit, was der Client zum Weiterarbeiten braucht – ohne neu zu
|
||||
* laden: die aktuelle Version und das Diff von seiner Basis dorthin. Er zeigt
|
||||
* dann zwei Knöpfe (fremde Fassung übernehmen / eigene durchsetzen); an dieser
|
||||
* Stelle gewinnt einer vollständig, aber nichts geht endgültig verloren, denn
|
||||
* jede Version steht in der Historie.
|
||||
*/
|
||||
class ContentConflictException(
|
||||
val currentVersion: Long,
|
||||
val opsSinceBase: List<LineOp>,
|
||||
) : RuntimeException("Überschneidung mit Version $currentVersion")
|
||||
|
||||
/**
|
||||
* Das Dokument ist gelöscht, seine Historie gibt es noch (404 mit Hinweis auf
|
||||
* die Wiederherstellung).
|
||||
*/
|
||||
class DocumentDeletedException(val id: UUID) :
|
||||
RuntimeException(
|
||||
"Dokument $id wurde gelöscht; es lässt sich über " +
|
||||
"POST /api/v1/documents/$id/restore wiederherstellen"
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.werkbaum.service
|
||||
|
||||
import de.werkbaum.domain.ChangeAuthor
|
||||
import de.werkbaum.domain.ChangeType
|
||||
import de.werkbaum.domain.Document
|
||||
import de.werkbaum.domain.DocumentHistoryEntry
|
||||
@@ -24,7 +25,10 @@ class DocumentService(
|
||||
fun findAll(): List<Document> = repository.findAll()
|
||||
|
||||
fun findById(id: UUID): Document =
|
||||
repository.findById(id) ?: throw DocumentNotFoundException(id)
|
||||
findByIdOrNull(id) ?: throw DocumentNotFoundException(id)
|
||||
|
||||
/** Wie [findById], nur ohne Ausnahme – wenn der Aufrufer selbst unterscheiden will. */
|
||||
fun findByIdOrNull(id: UUID): Document? = repository.findById(id)
|
||||
|
||||
fun create(title: String, content: String): Document {
|
||||
val now = OffsetDateTime.now(clock)
|
||||
@@ -50,7 +54,13 @@ class DocumentService(
|
||||
* ist dagegen eine bewusste Handlung (Import, Reparatur) und bleibt
|
||||
* Meilenstein.
|
||||
*/
|
||||
fun update(id: UUID, title: String, content: String, milestone: Boolean = true): Document {
|
||||
fun update(
|
||||
id: UUID,
|
||||
title: String,
|
||||
content: String,
|
||||
milestone: Boolean = true,
|
||||
author: ChangeAuthor? = null,
|
||||
): Document {
|
||||
val existing = findById(id)
|
||||
val updated = existing.copy(
|
||||
title = title,
|
||||
@@ -59,7 +69,7 @@ class DocumentService(
|
||||
updatedAt = OffsetDateTime.now(clock),
|
||||
)
|
||||
repository.save(updated)
|
||||
recordHistory(updated, ChangeType.UPDATED, milestone)
|
||||
recordHistory(updated, ChangeType.UPDATED, milestone, author)
|
||||
return updated
|
||||
}
|
||||
|
||||
@@ -167,6 +177,7 @@ class DocumentService(
|
||||
document: Document,
|
||||
changeType: ChangeType,
|
||||
milestone: Boolean = true,
|
||||
author: ChangeAuthor? = null,
|
||||
) {
|
||||
val previous = historyRepository.findLatest(document.id)
|
||||
if (previous != null && !previous.milestone &&
|
||||
@@ -184,6 +195,7 @@ class DocumentService(
|
||||
changeType = changeType,
|
||||
timestamp = document.updatedAt,
|
||||
milestone = milestone || changeType.isStructural,
|
||||
author = author,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -22,4 +22,13 @@ data class LiveEditingProperties(
|
||||
* ein so altes `since` mit Volltext statt mit einem Diff.
|
||||
*/
|
||||
val syncRetention: Duration = Duration.ofHours(1),
|
||||
|
||||
/**
|
||||
* Höchstzahl der Operationen je Anfrage. Ohne Grenze ist ein einzelner
|
||||
* Request ein Ausfall-Vektor – auch versehentlich, durch einen Client-Bug.
|
||||
*/
|
||||
val maxOps: Int = 1_000,
|
||||
|
||||
/** Höchstlänge des Dokuments in Zeichen; der mitgelieferte Plan hat ~40 000. */
|
||||
val maxContentLength: Int = 2_000_000,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package de.werkbaum.service
|
||||
|
||||
import de.werkbaum.diff.DiffNotApplicableException
|
||||
import de.werkbaum.diff.LineDiff
|
||||
import de.werkbaum.domain.ContentPatch
|
||||
import de.werkbaum.domain.ContentPatchOutcome
|
||||
import de.werkbaum.repository.DocumentHistoryRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
/**
|
||||
* Die eingereichte Änderung ist ungültig oder überschreitet eine
|
||||
* serverseitige Grenze (400). Ohne solche Grenzen ist ein einzelner Request
|
||||
* ein Ausfall-Vektor – auch versehentlich, durch einen Client-Bug.
|
||||
*/
|
||||
class InvalidPatchException(message: String) : RuntimeException(message)
|
||||
|
||||
/**
|
||||
* Das Live-Editing: Zeilen-Diffs einreichen (D76).
|
||||
*
|
||||
* Bewusst **ohne** `@Transactional`. Die Änderung eines Dokuments muss strikt
|
||||
* sequenziell laufen – prüfen, rebasen und anwenden gehören zusammen –, und
|
||||
* die Sperre dafür liegt **außerhalb** der Transaktion: Läge sie innen, gäbe
|
||||
* der Proxy sie vor dem Commit wieder frei, und der nächste Schreiber läse
|
||||
* einen Stand, der noch nicht steht. Geschrieben wird deshalb über die
|
||||
* transaktionalen Methoden von [DocumentService].
|
||||
*/
|
||||
@Service
|
||||
class LiveEditingService(
|
||||
private val documents: DocumentService,
|
||||
private val history: DocumentHistoryRepository,
|
||||
private val properties: LiveEditingProperties,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Feste Zahl von Sperren, verteilt über die UUID. Zwei Dokumente können
|
||||
* sich eine teilen – das kostet nur Zeit, nie Richtigkeit – und die Menge
|
||||
* wächst nie: eine Sperre je Dokument müsste beim Löschen aufgeräumt
|
||||
* werden und wäre sonst ein langsames Leck.
|
||||
*/
|
||||
private val stripes = Array(64) { ReentrantLock() }
|
||||
|
||||
/** Wiederholte Einreichungen erkennen (Idempotenz). */
|
||||
private val patchLog = PatchLog()
|
||||
|
||||
fun patchContent(documentId: UUID, patch: ContentPatch): ContentPatchOutcome =
|
||||
lockFor(documentId).withLock { applyPatch(documentId, patch) }
|
||||
|
||||
private fun applyPatch(documentId: UUID, patch: ContentPatch): ContentPatchOutcome {
|
||||
if (patch.ops.size > properties.maxOps) {
|
||||
throw InvalidPatchException(
|
||||
"Zu viele Operationen: ${patch.ops.size} (erlaubt: ${properties.maxOps})"
|
||||
)
|
||||
}
|
||||
|
||||
patchLog.outcomeOf(documentId, patch.author.clientId, patch.seq)?.let { return it }
|
||||
|
||||
val current = documents.findByIdOrNull(documentId)
|
||||
?: if (history.exists(documentId)) throw DocumentDeletedException(documentId)
|
||||
else throw DocumentNotFoundException(documentId)
|
||||
|
||||
val baseContent = baseContentOf(documentId, current.content, current.version, patch.baseVersion)
|
||||
if (LineDiff.checksum(baseContent) != patch.checksum) {
|
||||
throw DiffNotApplicableException(
|
||||
"Prüfsumme passt nicht zur Basisversion ${patch.baseVersion} – " +
|
||||
"beide Seiten lesen denselben Stand verschieden"
|
||||
)
|
||||
}
|
||||
|
||||
// Veraltete Basis: selbst rebasen, statt abzulehnen. Reines Ablehnen
|
||||
// führte zu Starvation - ein Client mit hoher Latenz käme bei
|
||||
// fleißigen Mitschreibern womöglich nie durch.
|
||||
val opsSinceBase =
|
||||
if (patch.baseVersion == current.version) emptyList()
|
||||
else LineDiff.compute(LineDiff.lines(baseContent), LineDiff.lines(current.content))
|
||||
|
||||
val ops = LineDiff.rebase(patch.ops, opsSinceBase)
|
||||
?: throw ContentConflictException(current.version, opsSinceBase)
|
||||
|
||||
val newContent = LineDiff.text(LineDiff.apply(LineDiff.lines(current.content), ops))
|
||||
if (newContent.length > properties.maxContentLength) {
|
||||
throw InvalidPatchException(
|
||||
"Dokument würde ${newContent.length} Zeichen lang " +
|
||||
"(erlaubt: ${properties.maxContentLength})"
|
||||
)
|
||||
}
|
||||
|
||||
val updated = documents.update(
|
||||
id = documentId,
|
||||
title = current.title,
|
||||
content = newContent,
|
||||
milestone = patch.milestone,
|
||||
author = patch.author,
|
||||
)
|
||||
|
||||
return ContentPatchOutcome(updated.version, opsSinceBase)
|
||||
.also { patchLog.record(documentId, patch.author.clientId, patch.seq, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Text, gegen den das Diff gebildet wurde. Der Normalfall ist die
|
||||
* aktuelle Version; sonst der Snapshot aus der Historie. Ist der bereits
|
||||
* verdichtet, lässt sich nicht mehr rebasen – dann bleibt nur einmal neu
|
||||
* laden (422), und das ist ehrlicher als ein geratenes Diff.
|
||||
*/
|
||||
private fun baseContentOf(
|
||||
documentId: UUID,
|
||||
currentContent: String,
|
||||
currentVersion: Long,
|
||||
baseVersion: Long,
|
||||
): String = when {
|
||||
baseVersion == currentVersion -> currentContent
|
||||
baseVersion > currentVersion -> throw DiffNotApplicableException(
|
||||
"Basisversion $baseVersion liegt vor der aktuellen Version $currentVersion"
|
||||
)
|
||||
|
||||
else -> history.findVersion(documentId, baseVersion)?.content
|
||||
?: throw DiffNotApplicableException(
|
||||
"Basisversion $baseVersion ist nicht mehr verfügbar (verdichtet)"
|
||||
)
|
||||
}
|
||||
|
||||
private fun lockFor(documentId: UUID): ReentrantLock =
|
||||
stripes[Math.floorMod(documentId.hashCode(), stripes.size)]
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.werkbaum.service
|
||||
|
||||
import de.werkbaum.domain.ContentPatchOutcome
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Merkt sich je (Dokument, Client) die zuletzt verarbeitete Sequenznummer samt
|
||||
* Ergebnis – damit ein wiederholtes Einreichen nicht ein zweites Mal wirkt.
|
||||
*
|
||||
* Geht die Antwort unterwegs verloren – im Mobilnetz der Normalfall –, weiß
|
||||
* der Client nicht, ob seine Änderung ankam (D76). Kurzlebig und im Speicher:
|
||||
* Das Fenster ist Sekunden lang, und eine Einzelinstanz ist ohnehin
|
||||
* vorausgesetzt. Die Zahl der gemerkten Paare ist gedeckelt; verdrängt wird
|
||||
* das am längsten nicht benutzte.
|
||||
*
|
||||
* Alle Methoden sind synchronisiert: Aufrufe kommen aus verschiedenen
|
||||
* Dokument-Sperren und damit echt nebenläufig.
|
||||
*/
|
||||
class PatchLog(private val capacity: Int = 1_024) {
|
||||
|
||||
private data class Seen(val seq: Long, val outcome: ContentPatchOutcome)
|
||||
|
||||
private val entries = object : LinkedHashMap<Pair<UUID, String>, Seen>(64, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: Map.Entry<Pair<UUID, String>, Seen>) =
|
||||
size > capacity
|
||||
}
|
||||
|
||||
/**
|
||||
* `null` heißt: neu, bitte anwenden. Ein Ergebnis heißt: schon erledigt,
|
||||
* das war die Antwort. Eine **kleinere** Sequenznummer als die zuletzt
|
||||
* verarbeitete ist ein Client-Fehler – das Ergebnis von damals ist nicht
|
||||
* mehr bekannt, und ein zweites Anwenden verdürbe den Text.
|
||||
*/
|
||||
@Synchronized
|
||||
fun outcomeOf(documentId: UUID, clientId: String, seq: Long): ContentPatchOutcome? {
|
||||
val seen = entries[documentId to clientId] ?: return null
|
||||
return when {
|
||||
seq > seen.seq -> null
|
||||
seq == seen.seq -> seen.outcome
|
||||
else -> throw StalePatchSequenceException(seq, seen.seq)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun record(documentId: UUID, clientId: String, seq: Long, outcome: ContentPatchOutcome) {
|
||||
entries[documentId to clientId] = Seen(seq, outcome)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun size(): Int = entries.size
|
||||
}
|
||||
|
||||
/** Eine ältere Sequenznummer als die zuletzt verarbeitete (422). */
|
||||
class StalePatchSequenceException(seq: Long, lastSeen: Long) :
|
||||
RuntimeException("Sequenznummer $seq ist veraltet (zuletzt verarbeitet: $lastSeen)")
|
||||
@@ -36,3 +36,11 @@ ALTER TABLE document_history ADD COLUMN milestone BOOLEAN DEFAULT TRUE NOT NULL;
|
||||
CREATE INDEX idx_document_history_version ON document_history (document_id, version);
|
||||
--rollback DROP INDEX idx_document_history_version;
|
||||
--rollback ALTER TABLE document_history DROP COLUMN milestone;
|
||||
|
||||
--changeset editor:005-history-author
|
||||
-- "Geaendert von" (D76): pseudonyme Kennung plus selbstgewaehlter Name. Beide
|
||||
-- optional - CRUD ohne Live-Editing kennt keinen Absender.
|
||||
ALTER TABLE document_history ADD COLUMN client_id VARCHAR(64);
|
||||
ALTER TABLE document_history ADD COLUMN display_name VARCHAR(64);
|
||||
--rollback ALTER TABLE document_history DROP COLUMN display_name;
|
||||
--rollback ALTER TABLE document_history DROP COLUMN client_id;
|
||||
|
||||
@@ -183,6 +183,67 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
|
||||
/documents/{documentId}/content:
|
||||
parameters:
|
||||
- name: documentId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
patch:
|
||||
tags: [Documents]
|
||||
operationId: patchDocumentContent
|
||||
summary: Zeilen-Diff auf den Inhalt anwenden (Live-Editing)
|
||||
description: >
|
||||
Reicht eine Aenderung als Zeilen-Diff gegen eine Basisversion ein.
|
||||
|
||||
|
||||
Ist die Basis veraltet, ueberschneiden sich die Operationen aber nicht
|
||||
mit den zwischenzeitlichen, verschiebt der Server sie selbst und
|
||||
akzeptiert (200); die fremden Operationen stehen dann in
|
||||
`opsSinceBase`, damit der Client seine Schattenkopie nachzieht. Nur
|
||||
bei echter Ueberschneidung antwortet er mit 409.
|
||||
|
||||
|
||||
`clientId` und `seq` machen den Aufruf wiederholbar: Eine Wiederholung
|
||||
liefert das Ergebnis von damals, statt die Aenderung ein zweites Mal
|
||||
anzuwenden.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ContentPatchRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Aenderung angenommen (ggf. serverseitig verschoben)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ContentPatchResult"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
description: >
|
||||
Echte Ueberschneidung mit einer zwischenzeitlichen Aenderung. Der
|
||||
Client entscheidet - fremde Fassung uebernehmen oder eigene
|
||||
durchsetzen -, ohne neu zu laden.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ContentConflict"
|
||||
"422":
|
||||
description: >
|
||||
Diff nicht anwendbar: Pruefsummenfehler, Index ausserhalb, oder die
|
||||
Basisversion ist nicht mehr verfuegbar. Der Client laedt einmalig neu.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
|
||||
components:
|
||||
responses:
|
||||
NotFound:
|
||||
@@ -293,6 +354,100 @@ components:
|
||||
Optionale Zielversion. Ohne Angabe wird der letzte inhaltliche
|
||||
Stand vor dem Loeschen wiederhergestellt.
|
||||
|
||||
LineOperation:
|
||||
description: >
|
||||
Eine Zeilen-Operation relativ zur Basisversion (0-basierter Index).
|
||||
Operationen sind aufsteigend sortiert und ueberschneiden sich nicht.
|
||||
type: object
|
||||
required: [op, index]
|
||||
properties:
|
||||
op:
|
||||
type: string
|
||||
enum: [replace, insert, delete]
|
||||
index:
|
||||
type: integer
|
||||
format: int32
|
||||
minimum: 0
|
||||
count:
|
||||
type: integer
|
||||
format: int32
|
||||
minimum: 0
|
||||
description: Zahl der betroffenen Basiszeilen; bei `insert` ohne Bedeutung.
|
||||
lines:
|
||||
type: array
|
||||
description: Einzusetzende Zeilen; bei `delete` ohne Bedeutung.
|
||||
items:
|
||||
type: string
|
||||
|
||||
ContentPatchRequest:
|
||||
type: object
|
||||
required: [baseVersion, checksum, clientId, seq, ops]
|
||||
properties:
|
||||
baseVersion:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Version, gegen die das Diff gebildet wurde.
|
||||
checksum:
|
||||
type: string
|
||||
description: >
|
||||
Pflicht. `sha256:<hex>` des vollstaendigen Basistexts mit
|
||||
LF-Zeilenenden. Die Versionsnummer bestaetigt nur, dass die Basis
|
||||
dieselbe Version ist, nicht dass beide Seiten sie gleich lesen.
|
||||
clientId:
|
||||
type: string
|
||||
maxLength: 64
|
||||
description: Zufaellige, pseudonyme Kennung des Clients.
|
||||
seq:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Laufende Nummer dieses Clients; macht den Aufruf wiederholbar.
|
||||
displayName:
|
||||
type: string
|
||||
maxLength: 64
|
||||
description: >
|
||||
Selbstgewaehlter Anzeigename. Ohne Anmeldung ist er eine
|
||||
Behauptung und darf in der Oberflaeche nicht wie ein Nachweis
|
||||
aussehen.
|
||||
milestone:
|
||||
type: boolean
|
||||
default: false
|
||||
description: >
|
||||
`true` schreibt einen nutzersichtbaren Stand (Knopfdruck).
|
||||
Der getaktete Strom laesst das Feld weg.
|
||||
ops:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/LineOperation"
|
||||
|
||||
ContentPatchResult:
|
||||
type: object
|
||||
required: [version, opsSinceBase]
|
||||
properties:
|
||||
version:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Neue Version des Dokuments.
|
||||
opsSinceBase:
|
||||
type: array
|
||||
description: >
|
||||
Leer, wenn die Basis noch aktuell war. Sonst die fremden
|
||||
Operationen, um die der Server verschoben hat.
|
||||
items:
|
||||
$ref: "#/components/schemas/LineOperation"
|
||||
|
||||
ContentConflict:
|
||||
type: object
|
||||
required: [currentVersion, opsSinceBase]
|
||||
properties:
|
||||
currentVersion:
|
||||
type: integer
|
||||
format: int64
|
||||
opsSinceBase:
|
||||
type: array
|
||||
description: Diff von der eingereichten Basis bis zur aktuellen Version.
|
||||
items:
|
||||
$ref: "#/components/schemas/LineOperation"
|
||||
|
||||
ProblemDetail:
|
||||
type: object
|
||||
description: Fehlerformat nach RFC 9457 (Problem Details)
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package de.werkbaum.bdd
|
||||
|
||||
import de.werkbaum.diff.LineDiff
|
||||
import de.werkbaum.generated.model.ContentConflict
|
||||
import de.werkbaum.generated.model.ContentPatchResult
|
||||
import de.werkbaum.generated.model.Document as ApiDocument
|
||||
import tools.jackson.databind.json.JsonMapper
|
||||
import tools.jackson.module.kotlin.kotlinModule
|
||||
import io.cucumber.java.de.Angenommen
|
||||
import io.cucumber.java.de.Dann
|
||||
import io.cucumber.java.de.Und
|
||||
import io.cucumber.java.de.Wenn
|
||||
import io.kotest.assertions.withClue
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.test.web.servlet.client.EntityExchangeResult
|
||||
import org.springframework.test.web.servlet.client.RestTestClient
|
||||
|
||||
/**
|
||||
* Behavior-Tests des Live-Editings (D76) gegen die laufende Anwendung.
|
||||
*
|
||||
* Die Schritte rechnen Basisversion und Prüfsumme selbst aus – genau wie ein
|
||||
* echter Client. Ein fest verdrahteter Hash im Feature wäre bei der ersten
|
||||
* Textänderung falsch, ohne dass es jemandem auffiele.
|
||||
*/
|
||||
class LiveEditingStepDefinitions {
|
||||
|
||||
@Autowired
|
||||
private lateinit var client: RestTestClient
|
||||
|
||||
private lateinit var documentId: String
|
||||
|
||||
/** Stand, den ein Client zuletzt gesehen hat: Version und Prüfsumme. */
|
||||
private val knownState = mutableMapOf<String, Pair<Long, String>>()
|
||||
|
||||
private var lastResponse: EntityExchangeResult<String>? = null
|
||||
private var lastRequestBody: String? = null
|
||||
|
||||
private fun status(): Int? = lastResponse?.status?.value()
|
||||
|
||||
private fun currentDocument(): ApiDocument =
|
||||
client.get()
|
||||
.uri("/api/v1/documents/$documentId")
|
||||
.exchange()
|
||||
.returnResult(ApiDocument::class.java)
|
||||
.responseBody
|
||||
.shouldNotBeNull()
|
||||
|
||||
private fun json(text: String): String = buildString {
|
||||
append('"')
|
||||
for (c in text) when (c) {
|
||||
'"' -> append("\\\"")
|
||||
'\\' -> append("\\\\")
|
||||
'\n' -> append("\\n")
|
||||
'\r' -> append("\\r")
|
||||
'\t' -> append("\\t")
|
||||
else -> append(c)
|
||||
}
|
||||
append('"')
|
||||
}
|
||||
|
||||
private fun sendPatch(body: String) {
|
||||
lastRequestBody = body
|
||||
lastResponse = client.patch()
|
||||
.uri("/api/v1/documents/$documentId/content")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(body)
|
||||
.exchange()
|
||||
.returnResult(String::class.java)
|
||||
}
|
||||
|
||||
private fun patchBody(
|
||||
clientId: String,
|
||||
ops: String,
|
||||
baseVersion: Long,
|
||||
checksum: String,
|
||||
seq: Long = 1,
|
||||
) = """
|
||||
{"baseVersion":$baseVersion,"checksum":${json(checksum)},
|
||||
"clientId":${json(clientId)},"seq":$seq,"ops":$ops}
|
||||
""".trimIndent()
|
||||
|
||||
private fun baseOf(clientId: String): Pair<Long, String> =
|
||||
knownState[clientId] ?: currentDocument().let { it.version to LineDiff.checksum(it.content) }
|
||||
|
||||
// ---------------- Angenommen ----------------
|
||||
|
||||
@Angenommen("es existiert ein Dokument {string} mit den Zeilen:")
|
||||
fun `es existiert ein Dokument mit Zeilen`(titel: String, inhalt: String) {
|
||||
val response = client.post()
|
||||
.uri("/api/v1/documents")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body("""{"title":${json(titel)},"content":${json(inhalt)}}""")
|
||||
.exchange()
|
||||
.returnResult(ApiDocument::class.java)
|
||||
withClue("Testdatenanlage fehlgeschlagen") { response.status.value() shouldBe 201 }
|
||||
documentId = response.responseBody.shouldNotBeNull().id.toString()
|
||||
knownState.clear()
|
||||
}
|
||||
|
||||
@Angenommen("Client {string} kennt den aktuellen Stand")
|
||||
fun `Client kennt den aktuellen Stand`(clientId: String) {
|
||||
val doc = currentDocument()
|
||||
knownState[clientId] = doc.version to LineDiff.checksum(doc.content)
|
||||
}
|
||||
|
||||
@Angenommen("dieses Dokument gelöscht wird")
|
||||
fun `dieses Dokument wird geloescht`() {
|
||||
client.delete()
|
||||
.uri("/api/v1/documents/$documentId")
|
||||
.exchange()
|
||||
.returnResult(String::class.java)
|
||||
.status.value() shouldBe 204
|
||||
}
|
||||
|
||||
// ---------------- Wenn ----------------
|
||||
|
||||
@Wenn("Client {string} folgendes Diff einreicht:")
|
||||
fun `Client reicht ein Diff ein`(clientId: String, ops: String) {
|
||||
val (version, checksum) = baseOf(clientId)
|
||||
sendPatch(patchBody(clientId, ops, version, checksum))
|
||||
}
|
||||
|
||||
@Wenn("Client {string} folgendes Diff mit falscher Prüfsumme einreicht:")
|
||||
fun `Client reicht ein Diff mit falscher Pruefsumme ein`(clientId: String, ops: String) {
|
||||
val (version, _) = baseOf(clientId)
|
||||
sendPatch(patchBody(clientId, ops, version, "sha256:" + "0".repeat(64)))
|
||||
}
|
||||
|
||||
@Wenn("dieselbe Anfrage noch einmal gesendet wird")
|
||||
fun `dieselbe Anfrage noch einmal`() {
|
||||
sendPatch(lastRequestBody.shouldNotBeNull())
|
||||
}
|
||||
|
||||
// ---------------- Dann / Und ----------------
|
||||
|
||||
@Dann("erhalte ich für das Diff den Status {int}")
|
||||
fun `erhalte ich fuer das Diff den Status`(erwartet: Int) {
|
||||
withClue("Antwort: ${lastResponse?.responseBody}") { status() shouldBe erwartet }
|
||||
}
|
||||
|
||||
@Und("das Dokument hat die Zeilen:")
|
||||
fun `das Dokument hat die Zeilen`(erwartet: String) {
|
||||
currentDocument().content shouldBe erwartet
|
||||
}
|
||||
|
||||
@Und("das Dokument hat die Version {long}")
|
||||
fun `das Dokument hat die Version`(erwartet: Long) {
|
||||
currentDocument().version shouldBe erwartet
|
||||
}
|
||||
|
||||
@Und("die Antwort meldet die Version {long}")
|
||||
fun `die Antwort meldet die Version`(erwartet: Long) {
|
||||
lastBody<ContentPatchResult>().version shouldBe erwartet
|
||||
}
|
||||
|
||||
@Und("die Antwort enthält {int} fremde Operationen")
|
||||
fun `die Antwort enthaelt n fremde Operationen`(anzahl: Int) {
|
||||
lastBody<ContentPatchResult>().opsSinceBase.size shouldBe anzahl
|
||||
}
|
||||
|
||||
@Und("die Konfliktantwort nennt die aktuelle Version {long} und {int} fremde Operationen")
|
||||
fun `die Konfliktantwort nennt`(version: Long, anzahl: Int) {
|
||||
val conflict = lastBody<ContentConflict>()
|
||||
conflict.currentVersion shouldBe version
|
||||
conflict.opsSinceBase.size shouldBe anzahl
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Rumpf der letzten Antwort, typisiert gelesen. Bewusst aus dem
|
||||
* gemerkten Text und nicht durch erneutes Senden: Ein zweiter Aufruf wäre
|
||||
* zwar idempotent, würde aber genau den Fehler verdecken, den er prüfen
|
||||
* soll.
|
||||
*/
|
||||
private inline fun <reified T : Any> lastBody(): T =
|
||||
mapper.readValue(lastResponse?.responseBody.shouldNotBeNull(), T::class.java)
|
||||
|
||||
private companion object {
|
||||
val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()).build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package de.werkbaum.service
|
||||
|
||||
import de.werkbaum.diff.DiffNotApplicableException
|
||||
import de.werkbaum.diff.LineDiff
|
||||
import de.werkbaum.diff.LineOp
|
||||
import de.werkbaum.domain.ChangeAuthor
|
||||
import de.werkbaum.domain.ChangeType
|
||||
import de.werkbaum.domain.ContentPatch
|
||||
import de.werkbaum.domain.ContentPatchOutcome
|
||||
import de.werkbaum.domain.Document
|
||||
import de.werkbaum.domain.DocumentHistoryEntry
|
||||
import de.werkbaum.repository.DocumentHistoryRepository
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
class LiveEditingServiceTest {
|
||||
|
||||
private val id = UUID.randomUUID()
|
||||
private val documents = mockk<DocumentService>()
|
||||
private val history = mockk<DocumentHistoryRepository>(relaxed = true)
|
||||
private val properties = LiveEditingProperties(maxOps = 3, maxContentLength = 40)
|
||||
private val service = LiveEditingService(documents, history, properties)
|
||||
|
||||
private val basis = "eins\nzwei\ndrei"
|
||||
|
||||
private fun document(content: String = basis, version: Long = 7) = Document(
|
||||
id = id,
|
||||
title = "Plan",
|
||||
content = content,
|
||||
version = version,
|
||||
createdAt = OffsetDateTime.parse("2026-01-01T12:00:00Z"),
|
||||
updatedAt = OffsetDateTime.parse("2026-01-01T12:00:00Z"),
|
||||
)
|
||||
|
||||
private fun patch(
|
||||
ops: List<LineOp> = listOf(LineOp.Replace(1, 1, listOf("ZWEI"))),
|
||||
baseVersion: Long = 7,
|
||||
checksum: String = LineDiff.checksum(basis),
|
||||
clientId: String = "anna",
|
||||
seq: Long = 1,
|
||||
milestone: Boolean = false,
|
||||
) = ContentPatch(
|
||||
baseVersion = baseVersion,
|
||||
checksum = checksum,
|
||||
author = ChangeAuthor(clientId, "Anna"),
|
||||
seq = seq,
|
||||
ops = ops,
|
||||
milestone = milestone,
|
||||
)
|
||||
|
||||
private fun expectUpdate(content: String, newVersion: Long = 8) {
|
||||
every { documents.update(id, "Plan", content, any(), any()) } returns
|
||||
document(content, newVersion)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `eine Aenderung auf aktueller Basis wird angewendet`() {
|
||||
every { documents.findByIdOrNull(id) } returns document()
|
||||
val content = slot<String>()
|
||||
every { documents.update(id, "Plan", capture(content), any(), any()) } answers {
|
||||
document(content.captured, 8)
|
||||
}
|
||||
|
||||
val outcome = service.patchContent(id, patch())
|
||||
|
||||
outcome shouldBe ContentPatchOutcome(8, emptyList())
|
||||
content.captured shouldBe "eins\nZWEI\ndrei"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `der Autor wandert in die Historie`() {
|
||||
every { documents.findByIdOrNull(id) } returns document()
|
||||
expectUpdate("eins\nZWEI\ndrei")
|
||||
val autor = slot<ChangeAuthor>()
|
||||
every { documents.update(id, any(), any(), any(), capture(autor)) } returns document()
|
||||
|
||||
service.patchContent(id, patch())
|
||||
|
||||
autor.captured shouldBe ChangeAuthor("anna", "Anna")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `der getaktete Strom schreibt Sync-Versionen, der Knopfdruck einen Meilenstein`() {
|
||||
every { documents.findByIdOrNull(id) } returns document()
|
||||
val meilenstein = slot<Boolean>()
|
||||
every { documents.update(id, any(), any(), capture(meilenstein), any()) } returns document()
|
||||
|
||||
service.patchContent(id, patch(seq = 1))
|
||||
meilenstein.captured shouldBe false
|
||||
|
||||
service.patchContent(id, patch(seq = 2, milestone = true))
|
||||
meilenstein.captured shouldBe true
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rebasen und Konflikt
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `eine veraltete Basis ohne Ueberschneidung wird verschoben`() {
|
||||
// Basis v6: "eins/zwei/drei". Fremd: eine Zeile vorn eingefuegt -> v7.
|
||||
val aktuell = "null\neins\nzwei\ndrei"
|
||||
every { documents.findByIdOrNull(id) } returns document(aktuell, 7)
|
||||
every { history.findVersion(id, 6) } returns historyEntry(basis, 6)
|
||||
val content = slot<String>()
|
||||
every { documents.update(id, "Plan", capture(content), any(), any()) } answers {
|
||||
document(content.captured, 8)
|
||||
}
|
||||
|
||||
val outcome = service.patchContent(id, patch(baseVersion = 6))
|
||||
|
||||
outcome.version shouldBe 8
|
||||
outcome.opsSinceBase shouldBe listOf(LineOp.Insert(0, listOf("null")))
|
||||
content.captured shouldBe "null\neins\nZWEI\ndrei"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eine echte Ueberschneidung meldet Konflikt samt fremdem Diff`() {
|
||||
val aktuell = "eins\nfremd\ndrei"
|
||||
every { documents.findByIdOrNull(id) } returns document(aktuell, 7)
|
||||
every { history.findVersion(id, 6) } returns historyEntry(basis, 6)
|
||||
|
||||
val konflikt = shouldThrow<ContentConflictException> {
|
||||
service.patchContent(id, patch(baseVersion = 6))
|
||||
}
|
||||
|
||||
konflikt.currentVersion shouldBe 7
|
||||
konflikt.opsSinceBase shouldBe listOf(LineOp.Replace(1, 1, listOf("fremd")))
|
||||
verify(exactly = 0) { documents.update(any(), any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Wiederholung
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `dieselbe Sequenznummer wirkt nur einmal`() {
|
||||
every { documents.findByIdOrNull(id) } returns document()
|
||||
expectUpdate("eins\nZWEI\ndrei")
|
||||
|
||||
val erste = service.patchContent(id, patch(seq = 4))
|
||||
val zweite = service.patchContent(id, patch(seq = 4))
|
||||
|
||||
zweite shouldBe erste
|
||||
verify(exactly = 1) { documents.update(any(), any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ein anderer Client teilt die Sequenznummer nicht`() {
|
||||
every { documents.findByIdOrNull(id) } returns document()
|
||||
expectUpdate("eins\nZWEI\ndrei")
|
||||
|
||||
service.patchContent(id, patch(seq = 4, clientId = "anna"))
|
||||
service.patchContent(id, patch(seq = 4, clientId = "ben"))
|
||||
|
||||
verify(exactly = 2) { documents.update(any(), any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eine veraltete Sequenznummer wird nicht angewendet`() {
|
||||
every { documents.findByIdOrNull(id) } returns document()
|
||||
expectUpdate("eins\nZWEI\ndrei")
|
||||
service.patchContent(id, patch(seq = 4))
|
||||
|
||||
shouldThrow<StalePatchSequenceException> { service.patchContent(id, patch(seq = 3)) }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Nicht anwendbar
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `eine falsche Pruefsumme wird nicht angewendet`() {
|
||||
every { documents.findByIdOrNull(id) } returns document()
|
||||
|
||||
shouldThrow<DiffNotApplicableException> {
|
||||
service.patchContent(id, patch(checksum = LineDiff.checksum("etwas anderes")))
|
||||
}
|
||||
verify(exactly = 0) { documents.update(any(), any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eine verdichtete Basisversion laesst sich nicht mehr rebasen`() {
|
||||
every { documents.findByIdOrNull(id) } returns document(version = 7)
|
||||
every { history.findVersion(id, 2) } returns null
|
||||
|
||||
shouldThrow<DiffNotApplicableException> { service.patchContent(id, patch(baseVersion = 2)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eine Basisversion aus der Zukunft wird abgewiesen`() {
|
||||
every { documents.findByIdOrNull(id) } returns document(version = 7)
|
||||
|
||||
shouldThrow<DiffNotApplicableException> { service.patchContent(id, patch(baseVersion = 9)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ein geloeschtes Dokument nennt den Weg zurueck`() {
|
||||
every { documents.findByIdOrNull(id) } returns null
|
||||
every { history.exists(id) } returns true
|
||||
|
||||
val ex = shouldThrow<DocumentDeletedException> { service.patchContent(id, patch()) }
|
||||
ex.message!!.contains("restore") shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eine gaenzlich unbekannte UUID ist schlicht nicht gefunden`() {
|
||||
every { documents.findByIdOrNull(id) } returns null
|
||||
every { history.exists(id) } returns false
|
||||
|
||||
shouldThrow<DocumentNotFoundException> { service.patchContent(id, patch()) }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Grenzen
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `zu viele Operationen werden abgewiesen, bevor irgendetwas geschieht`() {
|
||||
val zuViele = (0..3).map { LineOp.Insert(0, listOf("x")) }
|
||||
|
||||
shouldThrow<InvalidPatchException> { service.patchContent(id, patch(ops = zuViele)) }
|
||||
verify(exactly = 0) { documents.findByIdOrNull(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ein zu langes Ergebnis wird abgewiesen`() {
|
||||
every { documents.findByIdOrNull(id) } returns document()
|
||||
|
||||
shouldThrow<InvalidPatchException> {
|
||||
service.patchContent(id, patch(ops = listOf(LineOp.Insert(0, listOf("x".repeat(60))))))
|
||||
}
|
||||
verify(exactly = 0) { documents.update(any(), any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
private fun historyEntry(content: String, version: Long) = DocumentHistoryEntry(
|
||||
documentId = id,
|
||||
version = version,
|
||||
title = "Plan",
|
||||
content = content,
|
||||
changeType = ChangeType.UPDATED,
|
||||
timestamp = OffsetDateTime.parse("2026-01-01T12:00:00Z"),
|
||||
milestone = false,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
# language: de
|
||||
Funktionalität: Gemeinsam am selben Dokument arbeiten
|
||||
Als mehrere Bearbeiter eines Plans
|
||||
möchte ich Änderungen als Zeilen-Diff einreichen,
|
||||
damit niemand den Text der anderen überschreibt und nichts neu geladen
|
||||
werden muss.
|
||||
|
||||
Grundlage: Jede Änderung ist ein Diff gegen eine Basisversion. Ist die Basis
|
||||
veraltet, verschiebt der Server sie selbst — abgelehnt wird nur, was sich
|
||||
wirklich überschneidet.
|
||||
|
||||
Szenario: Eine Änderung auf aktueller Basis wird angewendet
|
||||
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
- [ ] Zwei
|
||||
- [ ] Drei
|
||||
"""
|
||||
Wenn Client "anna" folgendes Diff einreicht:
|
||||
"""
|
||||
[{"op":"replace","index":1,"count":1,"lines":["- [x] Zwei"]}]
|
||||
"""
|
||||
Dann erhalte ich für das Diff den Status 200
|
||||
Und die Antwort meldet die Version 2
|
||||
Und die Antwort enthält 0 fremde Operationen
|
||||
Und das Dokument hat die Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
- [x] Zwei
|
||||
- [ ] Drei
|
||||
"""
|
||||
|
||||
Szenario: Eine veraltete Basis ohne Überschneidung wird serverseitig verschoben
|
||||
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
- [ ] Zwei
|
||||
- [ ] Drei
|
||||
"""
|
||||
Und Client "anna" kennt den aktuellen Stand
|
||||
Wenn Client "ben" folgendes Diff einreicht:
|
||||
"""
|
||||
[{"op":"replace","index":0,"count":1,"lines":["- [x] Eins"]}]
|
||||
"""
|
||||
Und Client "anna" folgendes Diff einreicht:
|
||||
"""
|
||||
[{"op":"replace","index":2,"count":1,"lines":["- [x] Drei"]}]
|
||||
"""
|
||||
Dann erhalte ich für das Diff den Status 200
|
||||
Und die Antwort enthält 1 fremde Operationen
|
||||
Und das Dokument hat die Zeilen:
|
||||
"""
|
||||
- [x] Eins
|
||||
- [ ] Zwei
|
||||
- [x] Drei
|
||||
"""
|
||||
|
||||
Szenario: Eine veraltete Basis mit Überschneidung meldet einen Konflikt
|
||||
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
- [ ] Zwei
|
||||
"""
|
||||
Und Client "anna" kennt den aktuellen Stand
|
||||
Wenn Client "ben" folgendes Diff einreicht:
|
||||
"""
|
||||
[{"op":"replace","index":0,"count":1,"lines":["- [x] Eins, von Ben"]}]
|
||||
"""
|
||||
Und Client "anna" folgendes Diff einreicht:
|
||||
"""
|
||||
[{"op":"replace","index":0,"count":1,"lines":["- [x] Eins, von Anna"]}]
|
||||
"""
|
||||
Dann erhalte ich für das Diff den Status 409
|
||||
Und die Konfliktantwort nennt die aktuelle Version 2 und 1 fremde Operationen
|
||||
Und das Dokument hat die Zeilen:
|
||||
"""
|
||||
- [x] Eins, von Ben
|
||||
- [ ] Zwei
|
||||
"""
|
||||
|
||||
Szenario: Zwei Einfügungen an derselben Stelle sind kein Konflikt
|
||||
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
- [ ] Zwei
|
||||
"""
|
||||
Und Client "anna" kennt den aktuellen Stand
|
||||
Wenn Client "ben" folgendes Diff einreicht:
|
||||
"""
|
||||
[{"op":"insert","index":1,"lines":["- [ ] Von Ben"]}]
|
||||
"""
|
||||
Und Client "anna" folgendes Diff einreicht:
|
||||
"""
|
||||
[{"op":"insert","index":1,"lines":["- [ ] Von Anna"]}]
|
||||
"""
|
||||
Dann erhalte ich für das Diff den Status 200
|
||||
Und das Dokument hat die Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
- [ ] Von Ben
|
||||
- [ ] Von Anna
|
||||
- [ ] Zwei
|
||||
"""
|
||||
|
||||
Szenario: Dieselbe Änderung zweimal gesendet wirkt nur einmal
|
||||
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
"""
|
||||
Wenn Client "anna" folgendes Diff einreicht:
|
||||
"""
|
||||
[{"op":"insert","index":1,"lines":["- [ ] Zwei"]}]
|
||||
"""
|
||||
Und dieselbe Anfrage noch einmal gesendet wird
|
||||
Dann erhalte ich für das Diff den Status 200
|
||||
Und die Antwort meldet die Version 2
|
||||
Und das Dokument hat die Version 2
|
||||
Und das Dokument hat die Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
- [ ] Zwei
|
||||
"""
|
||||
|
||||
Szenario: Eine falsche Prüfsumme wird nicht angewendet
|
||||
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
"""
|
||||
Wenn Client "anna" folgendes Diff mit falscher Prüfsumme einreicht:
|
||||
"""
|
||||
[{"op":"replace","index":0,"count":1,"lines":["- [x] Eins"]}]
|
||||
"""
|
||||
Dann erhalte ich für das Diff den Status 422
|
||||
Und das Dokument hat die Version 1
|
||||
|
||||
Szenario: Ein Index außerhalb des Dokuments wird nicht angewendet
|
||||
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
"""
|
||||
Wenn Client "anna" folgendes Diff einreicht:
|
||||
"""
|
||||
[{"op":"delete","index":7,"count":1}]
|
||||
"""
|
||||
Dann erhalte ich für das Diff den Status 422
|
||||
Und das Dokument hat die Version 1
|
||||
|
||||
Szenario: Ein gelöschtes Dokument nimmt keine Änderung mehr an
|
||||
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
"""
|
||||
Und Client "anna" kennt den aktuellen Stand
|
||||
Und dieses Dokument gelöscht wird
|
||||
Wenn Client "anna" folgendes Diff einreicht:
|
||||
"""
|
||||
[{"op":"replace","index":0,"count":1,"lines":["- [x] Eins"]}]
|
||||
"""
|
||||
Dann erhalte ich für das Diff den Status 404
|
||||
@@ -6054,3 +6054,63 @@ fünfzehn Aufrufen in einer Datei — die Zahl wächst von hier an nur. Weil
|
||||
Cucumber Senden (Wenn) und Prüfen (Dann) trennt, wird die fluent API nicht
|
||||
für Zusicherungen genutzt, sondern über `returnResult` das Ergebnis
|
||||
festgehalten.
|
||||
|
||||
**Nachtrag 4 — beim Bauen entschieden (2026-08-26).** Die Schritte 1–3 der
|
||||
Umsetzungsreihenfolge stehen (Zeilen-Diff, zweistufige Historie,
|
||||
`PATCH /content`). Sechs Dinge waren dabei zu entscheiden, die das Konzept
|
||||
offengelassen hat:
|
||||
|
||||
**Eine Einfügung liegt ZWISCHEN den Zeilen.** Das Konzept nennt sie einen
|
||||
„Punkt bei `index`" und lässt die Ränder offen. Umgesetzt ist die strikte
|
||||
Lesart `start < index < end`: Die Einfügung kollidiert nur mit dem **Inneren**
|
||||
eines fremden Bereichs. Beide im Konzept genannten Folgen gelten damit weiter —
|
||||
zwei Einfügungen an derselben Stelle vertragen sich, eine Einfügung in einen
|
||||
gelöschten Bereich nicht —, aber die Ränder bleiben konfliktfrei. Das ist der
|
||||
häufige Fall: Wer eine Zeile über einer gerade geänderten einfügt, meint
|
||||
eindeutig „davor" und soll keinen 409 bekommen. Die halboffene Variante
|
||||
(`start <= index < end`) hätte genau diesen Alltagsfall zum Konflikt erklärt,
|
||||
ohne dass ihm eine Mehrdeutigkeit zugrunde läge.
|
||||
|
||||
**Meilensteine entstehen rückwirkend, ohne Zeitgeber.** „Nach einer
|
||||
Schreibpause" klingt nach einem Timer; gebaut ist die Umkehrung: Die **nächste**
|
||||
Änderung stellt fest, dass eine Pause war, und befördert die Version davor. Das
|
||||
braucht keinen Hintergrund-Thread, ist mit fester Uhr prüfbar und trifft
|
||||
genau den gemeinten Stand — die letzte vor der Pause, nicht die erste danach.
|
||||
Die Lücke am Ende (nach der letzten Änderung kommt keine mehr) schließt die
|
||||
Historie-Abfrage, indem sie den jüngsten Stand immer mitliefert.
|
||||
|
||||
**Restore liest den Tombstone.** Bisher übersprang die Wiederherstellung den
|
||||
`DELETED`-Eintrag und nahm den letzten inhaltlichen davor. Inhaltlich sind
|
||||
beide gleich — aber der davor ist womöglich eine Sync-Version und damit
|
||||
verdichtet, der Tombstone dagegen ein Meilenstein und bleibt. Verhalten
|
||||
unverändert, Verlässlichkeit gewonnen.
|
||||
|
||||
**Die Sperre liegt außerhalb der Transaktion.** „Locking pro Dokument-UUID"
|
||||
und `@Transactional` an derselben Methode wäre falsch: Der Proxy gibt die
|
||||
Sperre vor dem Commit frei, und der nächste Schreiber läse einen Stand, der
|
||||
noch nicht steht. Deshalb ist `LiveEditingService` **nicht** transaktional und
|
||||
schreibt über die transaktionalen Methoden des `DocumentService`. Die Sperren
|
||||
sind ein festes Feld von 64 (Striping über die UUID): Zwei Dokumente können
|
||||
sich eine teilen — das kostet Zeit, nie Richtigkeit —, und die Menge wächst
|
||||
nie. Eine Sperre je Dokument müsste beim Löschen aufgeräumt werden und wäre
|
||||
sonst ein langsames Leck.
|
||||
|
||||
**Die Idempotenz lebt im Speicher, gedeckelt.** Je (Dokument, Client) die
|
||||
zuletzt verarbeitete `seq` samt Ergebnis, verdrängt wird das am längsten nicht
|
||||
benutzte. Persistenz wäre eine weitere Tabelle für ein Fenster von Sekunden;
|
||||
die Einzelinstanz ist ohnehin vorausgesetzt (Long Polling). Eine **kleinere**
|
||||
`seq` als die zuletzt verarbeitete ist ein eigener Fehler (422): Das Ergebnis
|
||||
von damals ist nicht mehr bekannt, und ein zweites Anwenden verdürbe den Text.
|
||||
|
||||
**400 gegen 422, sauber getrennt.** 422 heißt „richtig gebaut, aber nicht
|
||||
anwendbar" — Prüfsumme, Index, verdichtete Basis, veraltete `seq`; der Client
|
||||
lädt einmal neu und es geht weiter. 400 heißt „so nicht gefragt" —
|
||||
Grenzüberschreitung oder ein `delete`/`replace` **ohne `count`**. Letzteres
|
||||
wird bewusst **nicht** als 0 gelesen: Die Operation täte dann stillschweigend
|
||||
nichts bzw. würde zur Einfügung, und ein stiller Fehler ist in diesem Projekt
|
||||
durchweg der schlechtere (SPEC §4, D59).
|
||||
|
||||
Zahlen: 104 Tests. Gegenproben je Regel — Prüfsumme nicht geprüft, Idempotenz
|
||||
entfernt, veraltete Basis abgelehnt statt verschoben, Schreibpause ignoriert,
|
||||
Rückfall wieder als `RESTORED`, jüngster Stand aus der Historie genommen: Es
|
||||
fallen jeweils genau die danach benannten Zusicherungen.
|
||||
|
||||
Reference in New Issue
Block a user