diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index addcac5..4b4cd01 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -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. diff --git a/backend/README.md b/backend/README.md index 7bac5c6..1900591 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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. diff --git a/backend/docs/live-editing-proposal.md b/backend/docs/live-editing-proposal.md index fa8a583..2c0c53a 100644 --- a/backend/docs/live-editing-proposal.md +++ b/backend/docs/live-editing-proposal.md @@ -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) diff --git a/backend/src/main/kotlin/de/werkbaum/api/DocumentsController.kt b/backend/src/main/kotlin/de/werkbaum/api/DocumentsController.kt index aae9a55..3584855 100644 --- a/backend/src/main/kotlin/de/werkbaum/api/DocumentsController.kt +++ b/backend/src/main/kotlin/de/werkbaum/api/DocumentsController.kt @@ -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> = @@ -57,6 +63,32 @@ class DocumentsController( return ResponseEntity.noContent().build() } + override fun patchDocumentContent( + documentId: UUID, + contentPatchRequest: ContentPatchRequest, + ): ResponseEntity { + 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> = ResponseEntity.ok(service.history(documentId).map { it.toApi() }) diff --git a/backend/src/main/kotlin/de/werkbaum/api/GlobalExceptionHandler.kt b/backend/src/main/kotlin/de/werkbaum/api/GlobalExceptionHandler.kt index 0e36490..e2f1a24 100644 --- a/backend/src/main/kotlin/de/werkbaum/api/GlobalExceptionHandler.kt +++ b/backend/src/main/kotlin/de/werkbaum/api/GlobalExceptionHandler.kt @@ -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 = + 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" } } diff --git a/backend/src/main/kotlin/de/werkbaum/api/LineOperationMapper.kt b/backend/src/main/kotlin/de/werkbaum/api/LineOperationMapper.kt new file mode 100644 index 0000000..0495110 --- /dev/null +++ b/backend/src/main/kotlin/de/werkbaum/api/LineOperationMapper.kt @@ -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.toApi(): List = map { it.toApi() } diff --git a/backend/src/main/kotlin/de/werkbaum/domain/ContentPatch.kt b/backend/src/main/kotlin/de/werkbaum/domain/ContentPatch.kt new file mode 100644 index 0000000..c32f86d --- /dev/null +++ b/backend/src/main/kotlin/de/werkbaum/domain/ContentPatch.kt @@ -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, + 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, +) diff --git a/backend/src/main/kotlin/de/werkbaum/domain/DocumentHistoryEntry.kt b/backend/src/main/kotlin/de/werkbaum/domain/DocumentHistoryEntry.kt index d9e2de8..57beeb7 100644 --- a/backend/src/main/kotlin/de/werkbaum/domain/DocumentHistoryEntry.kt +++ b/backend/src/main/kotlin/de/werkbaum/domain/DocumentHistoryEntry.kt @@ -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, ) diff --git a/backend/src/main/kotlin/de/werkbaum/persistence/DocumentHistoryEntity.kt b/backend/src/main/kotlin/de/werkbaum/persistence/DocumentHistoryEntity.kt index 5f499b8..133d03c 100644 --- a/backend/src/main/kotlin/de/werkbaum/persistence/DocumentHistoryEntity.kt +++ b/backend/src/main/kotlin/de/werkbaum/persistence/DocumentHistoryEntity.kt @@ -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, ) } } diff --git a/backend/src/main/kotlin/de/werkbaum/service/ContentConflictException.kt b/backend/src/main/kotlin/de/werkbaum/service/ContentConflictException.kt new file mode 100644 index 0000000..3887c63 --- /dev/null +++ b/backend/src/main/kotlin/de/werkbaum/service/ContentConflictException.kt @@ -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, +) : 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" + ) diff --git a/backend/src/main/kotlin/de/werkbaum/service/DocumentService.kt b/backend/src/main/kotlin/de/werkbaum/service/DocumentService.kt index 265f7d1..e8006e7 100644 --- a/backend/src/main/kotlin/de/werkbaum/service/DocumentService.kt +++ b/backend/src/main/kotlin/de/werkbaum/service/DocumentService.kt @@ -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 = 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, ) ) diff --git a/backend/src/main/kotlin/de/werkbaum/service/LiveEditingProperties.kt b/backend/src/main/kotlin/de/werkbaum/service/LiveEditingProperties.kt index 50f4203..f4cc43d 100644 --- a/backend/src/main/kotlin/de/werkbaum/service/LiveEditingProperties.kt +++ b/backend/src/main/kotlin/de/werkbaum/service/LiveEditingProperties.kt @@ -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, ) diff --git a/backend/src/main/kotlin/de/werkbaum/service/LiveEditingService.kt b/backend/src/main/kotlin/de/werkbaum/service/LiveEditingService.kt new file mode 100644 index 0000000..58b0cf9 --- /dev/null +++ b/backend/src/main/kotlin/de/werkbaum/service/LiveEditingService.kt @@ -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)] + +} diff --git a/backend/src/main/kotlin/de/werkbaum/service/PatchLog.kt b/backend/src/main/kotlin/de/werkbaum/service/PatchLog.kt new file mode 100644 index 0000000..7545b86 --- /dev/null +++ b/backend/src/main/kotlin/de/werkbaum/service/PatchLog.kt @@ -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, Seen>(64, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry, 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)") diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.sql b/backend/src/main/resources/db/changelog/db.changelog-master.sql index e5625ee..4d5df00 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.sql +++ b/backend/src/main/resources/db/changelog/db.changelog-master.sql @@ -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; diff --git a/backend/src/main/resources/openapi/api.yaml b/backend/src/main/resources/openapi/api.yaml index a44742f..88f3cdf 100644 --- a/backend/src/main/resources/openapi/api.yaml +++ b/backend/src/main/resources/openapi/api.yaml @@ -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:` 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) diff --git a/backend/src/test/kotlin/de/werkbaum/bdd/LiveEditingStepDefinitions.kt b/backend/src/test/kotlin/de/werkbaum/bdd/LiveEditingStepDefinitions.kt new file mode 100644 index 0000000..9e95eda --- /dev/null +++ b/backend/src/test/kotlin/de/werkbaum/bdd/LiveEditingStepDefinitions.kt @@ -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>() + + private var lastResponse: EntityExchangeResult? = 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 = + 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().version shouldBe erwartet + } + + @Und("die Antwort enthält {int} fremde Operationen") + fun `die Antwort enthaelt n fremde Operationen`(anzahl: Int) { + lastBody().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() + 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 lastBody(): T = + mapper.readValue(lastResponse?.responseBody.shouldNotBeNull(), T::class.java) + + private companion object { + val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()).build() + } +} diff --git a/backend/src/test/kotlin/de/werkbaum/service/LiveEditingServiceTest.kt b/backend/src/test/kotlin/de/werkbaum/service/LiveEditingServiceTest.kt new file mode 100644 index 0000000..143a107 --- /dev/null +++ b/backend/src/test/kotlin/de/werkbaum/service/LiveEditingServiceTest.kt @@ -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() + private val history = mockk(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 = 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() + 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() + 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() + 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() + 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 { + 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 { service.patchContent(id, patch(seq = 3)) } + } + + // ----------------------------------------------------------------------- + // Nicht anwendbar + // ----------------------------------------------------------------------- + + @Test + fun `eine falsche Pruefsumme wird nicht angewendet`() { + every { documents.findByIdOrNull(id) } returns document() + + shouldThrow { + 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 { service.patchContent(id, patch(baseVersion = 2)) } + } + + @Test + fun `eine Basisversion aus der Zukunft wird abgewiesen`() { + every { documents.findByIdOrNull(id) } returns document(version = 7) + + shouldThrow { 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 { 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 { 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 { 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 { + 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, + ) +} diff --git a/backend/src/test/resources/features/live-editing.feature b/backend/src/test/resources/features/live-editing.feature new file mode 100644 index 0000000..80cde4d --- /dev/null +++ b/backend/src/test/resources/features/live-editing.feature @@ -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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 0640bb0..8110e4a 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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.