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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-26 17:05:14 +02:00
co-authored by Claude Opus 5
parent d5cdff6058
commit ae85503b79
20 changed files with 1321 additions and 35 deletions
@@ -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;
+155
View File
@@ -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