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)