feat(backend): Historie in zwei Ebenen, gezielter Repository-Zugriff (Schritt 2)

Meilensteine sind die nutzersichtbare Historie und bleiben; Sync-Versionen
tragen die Diffs des Live-Editings und werden nach der Aufbewahrungsfrist
verdichtet (D76). Ohne die Trennung wuerde die Historie beim getakteten
Schreiben zum Transaktionslog.

Die Schreibpause braucht keinen Zeitgeber: Die naechste Aenderung stellt
fest, dass eine Pause war, und befoerdert die Version davor nachtraeglich.
Strukturelle Aenderungen sind immer Meilensteine.

Das Historie-Repository greift jetzt gezielt zu (eine Version, juengster,
aeltester, Meilensteine, maxVersion) statt stets alle Eintraege zu laden und
in Kotlin zu filtern — bei hunderten Volltext-Versionen je Dokument war das
untragbar. Restore liest den letzten Stand aus dem Tombstone: der ueberlebt
das Verdichten, die Version davor womoeglich nicht.

Dabei die D76-Unschaerfe aufgeloest: RESTORED heisst nur noch "ein
geloeschtes Dokument ist wieder da" (der Client hebt seine Sperre auf), der
Rueckfall eines lebenden Dokuments ist ROLLED_BACK.

81 Tests. Gegenprobe: Schreibpause ignoriert -> genau die danach benannte
Zusicherung faellt; Rueckfall wieder als RESTORED -> Unit- und
Cucumber-Test dazu; juengster Stand aus der Historie genommen -> genau einer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-26 16:51:46 +02:00
co-authored by Claude Opus 5
parent 0f3bd8f5b8
commit d5cdff6058
16 changed files with 530 additions and 94 deletions
+4 -2
View File
@@ -6,8 +6,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 ist entschieden (D76), aber noch
nicht gebaut: `docs/live-editing-proposal.md`.
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.
## Konventionen
- Kotlin, **Spring Boot 4**, Gradle (Kotlin DSL), JDK 21.
+31 -15
View File
@@ -61,16 +61,31 @@ werden.
## Historie & Wiederherstellung
- Jede Änderung (CREATED, UPDATED, DELETED, RESTORED) wird als Snapshot in
einer vom Dokument getrennten Historie protokolliert sie **überlebt ein
DELETE**.
- `GET /api/v1/documents/{uuid}/history` liefert alle Einträge (älteste
zuerst); Identifier ist die UUID, wie bei GET (der Titel ist nicht eindeutig).
- Jede Änderung wird als Snapshot in einer vom Dokument getrennten Historie
protokolliert sie **überlebt ein DELETE**.
- **Zwei Ebenen** (D76): **Meilensteine** sind die nutzersichtbare Historie und
bleiben; **Sync-Versionen** tragen die Diffs des Live-Editings, sind
kurzlebig und werden verdichtet. Ohne die Trennung würde die Historie beim
getakteten Schreiben zum Transaktionslog hunderte Volltext-Snapshots eines
40-kB-Dokuments je Sitzung.
- Meilenstein wird ein Stand bei einer strukturellen Änderung (Anlegen,
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.
- Stellschrauben: `werkbaum.live-editing.milestone-pause` (30 s) und
`sync-retention` (1 h).
- `GET /api/v1/documents/{uuid}/history` liefert die Meilensteine (älteste
zuerst) und immer den jüngsten Stand; Identifier ist die UUID, wie bei GET
(der Titel ist nicht eindeutig).
- `POST /api/v1/documents/{uuid}/restore` stellt ein gelöschtes Dokument unter
derselben UUID wieder her (letzter Stand vor dem Löschen). Mit optionalem
Body `{"version": n}` wird eine bestimmte Version wiederhergestellt das
funktioniert auch als Rollback für noch existierende Dokumente; ohne
Zielversion antwortet der Server bei existierendem Dokument mit 409.
derselben UUID wieder her (`RESTORED`, letzter Stand aus dem Tombstone). Mit
optionalem Body `{"version": n}` wird eine bestimmte Version übernommen bei
einem noch lebenden Dokument ist das ein Rückfall (`ROLLED_BACK`), kein
Wiederherstellen: Der Client hatte nie eine Sperre. Ohne Zielversion
antwortet der Server bei existierendem Dokument mit 409, eine bereits
verdichtete Zielversion mit 404.
## Vorbereitete Erweiterungen
@@ -81,12 +96,13 @@ werden.
Spec; die Behavior-Tests erhalten dann einen Auth-Schritt
(„Angenommen ich bin als … angemeldet").
**Live-Editing**
- Jedes Dokument trägt eine `version`, die bei jedem Update inkrementiert
wird Basis für Optimistic Locking (HTTP 409 ist in der Spec bereits
reserviert) und für Delta-Synchronisation über WebSocket/STOMP.
- `DocumentUpdateRequest.expectedVersion` ist bereits im Vertrag vorgesehen,
wird aber noch nicht ausgewertet.
**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.
- `DocumentUpdateRequest.expectedVersion` ist im Vertrag vorgesehen, wird aber
noch nicht ausgewertet.
**Clientseitige Verschlüsselung**
- `content` ist ein opaker String, den der Server nie interpretiert. Der
@@ -1,9 +1,11 @@
package de.werkbaum
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
@SpringBootApplication
@ConfigurationPropertiesScan
class EditorBackendApplication
fun main(args: Array<String>) {
@@ -3,12 +3,33 @@ package de.werkbaum.domain
import java.time.OffsetDateTime
import java.util.UUID
enum class ChangeType { CREATED, UPDATED, DELETED, RESTORED }
/**
* Art der Änderung.
*
* [RESTORED] heißt ausschließlich: ein **gelöschtes** Dokument ist wieder da —
* der Client hebt daraufhin seine Sperre auf. [ROLLED_BACK] ist der Rückfall
* eines **lebenden** Dokuments auf eine alte Version; für den Client ein
* gewöhnlicher Inhaltswechsel. Beide trugen früher denselben Typ; ein Typ, der
* zwei Dinge bedeutet, ist die Unschärfe, aus der später Fehler werden (D76).
*/
enum class ChangeType { CREATED, UPDATED, DELETED, RESTORED, ROLLED_BACK }
/**
* Ein Eintrag der Dokumenthistorie. Die Historie wird getrennt vom Dokument
* gespeichert und überlebt daher ein DELETE Grundlage für die
* Wiederherstellung und später auch für Audit/Live-Editing-Replays.
* Wiederherstellung und für die Diffs des Live-Editings.
*
* Die Historie hat **zwei Ebenen** (D76):
* - [milestone] `false` — eine **Sync-Version**. Sie trägt das Protokoll
* (Diffs zwischen beliebigen Versionen), ist kurzlebig und wird nach einer
* Weile verdichtet. Danach beantwortet der Feed betroffene `since`-Werte
* mit Volltext.
* - [milestone] `true` — ein **Meilenstein**, die nutzersichtbare Historie.
* Meilensteine entstehen bei strukturellen Änderungen, nach einer
* Schreibpause und auf Knopfdruck; sie werden nie verdichtet.
*
* Ohne die Trennung würde die Historie bei 1,5 s Debounce zum Transaktionslog:
* hunderte Volltext-Snapshots eines 40-kB-Dokuments je Sitzung.
*/
data class DocumentHistoryEntry(
val documentId: UUID,
@@ -17,4 +38,5 @@ data class DocumentHistoryEntry(
val content: String,
val changeType: ChangeType,
val timestamp: OffsetDateTime,
val milestone: Boolean = true,
)
@@ -38,6 +38,10 @@ class DocumentHistoryEntity(
@Column(name = "change_time", nullable = false)
val changeTime: OffsetDateTime,
/** Meilenstein (nutzersichtbar, bleibt) oder Sync-Version (wird verdichtet) D76. */
@Column(nullable = false)
var milestone: Boolean = true,
) {
fun toDomain() = DocumentHistoryEntry(
documentId = documentId,
@@ -46,6 +50,7 @@ class DocumentHistoryEntity(
content = content,
changeType = changeType,
timestamp = changeTime,
milestone = milestone,
)
companion object {
@@ -56,6 +61,7 @@ class DocumentHistoryEntity(
content = entry.content,
changeType = entry.changeType,
changeTime = entry.timestamp,
milestone = entry.milestone,
)
}
}
@@ -3,6 +3,7 @@ package de.werkbaum.persistence
import de.werkbaum.domain.DocumentHistoryEntry
import de.werkbaum.repository.DocumentHistoryRepository
import org.springframework.stereotype.Repository
import java.time.OffsetDateTime
import java.util.UUID
@Repository
@@ -14,8 +15,28 @@ class JpaDocumentHistoryRepository(
jpa.save(DocumentHistoryEntity.fromDomain(entry))
}
override fun findByDocumentId(documentId: UUID): List<DocumentHistoryEntry> =
jpa.findByDocumentIdOrderByIdAsc(documentId).map { it.toDomain() }
override fun exists(documentId: UUID): Boolean = jpa.existsByDocumentId(documentId)
override fun findVersion(documentId: UUID, version: Long): DocumentHistoryEntry? =
jpa.findFirstByDocumentIdAndVersionOrderByIdDesc(documentId, version)?.toDomain()
override fun findLatest(documentId: UUID): DocumentHistoryEntry? =
jpa.findFirstByDocumentIdOrderByIdDesc(documentId)?.toDomain()
override fun findOldest(documentId: UUID): DocumentHistoryEntry? =
jpa.findFirstByDocumentIdOrderByIdAsc(documentId)?.toDomain()
override fun maxVersion(documentId: UUID): Long? = jpa.maxVersion(documentId)
override fun findMilestones(documentId: UUID): List<DocumentHistoryEntry> =
jpa.findByDocumentIdAndMilestoneTrueOrderByIdAsc(documentId).map { it.toDomain() }
override fun promoteToMilestone(documentId: UUID, version: Long) {
jpa.promoteToMilestone(documentId, version)
}
override fun compact(documentId: UUID, olderThan: OffsetDateTime): Int =
jpa.deleteSyncVersionsOlderThan(documentId, olderThan)
override fun clear() = jpa.deleteAll()
}
@@ -1,10 +1,53 @@
package de.werkbaum.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.time.OffsetDateTime
import java.util.UUID
interface DocumentJpaRepository : JpaRepository<DocumentEntity, UUID>
interface DocumentHistoryJpaRepository : JpaRepository<DocumentHistoryEntity, Long> {
fun findByDocumentIdOrderByIdAsc(documentId: UUID): List<DocumentHistoryEntity>
fun existsByDocumentId(documentId: UUID): Boolean
fun findFirstByDocumentIdAndVersionOrderByIdDesc(
documentId: UUID,
version: Long,
): DocumentHistoryEntity?
fun findFirstByDocumentIdOrderByIdDesc(documentId: UUID): DocumentHistoryEntity?
fun findFirstByDocumentIdOrderByIdAsc(documentId: UUID): DocumentHistoryEntity?
fun findByDocumentIdAndMilestoneTrueOrderByIdAsc(documentId: UUID): List<DocumentHistoryEntity>
@Query("select max(e.version) from DocumentHistoryEntity e where e.documentId = :documentId")
fun maxVersion(@Param("documentId") documentId: UUID): Long?
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(
"""
update DocumentHistoryEntity e set e.milestone = true
where e.documentId = :documentId and e.version = :version
"""
)
fun promoteToMilestone(
@Param("documentId") documentId: UUID,
@Param("version") version: Long,
): Int
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(
"""
delete from DocumentHistoryEntity e
where e.documentId = :documentId and e.milestone = false and e.changeTime < :cutoff
"""
)
fun deleteSyncVersionsOlderThan(
@Param("documentId") documentId: UUID,
@Param("cutoff") cutoff: OffsetDateTime,
): Int
}
@@ -1,13 +1,49 @@
package de.werkbaum.repository
import de.werkbaum.domain.DocumentHistoryEntry
import java.time.OffsetDateTime
import java.util.UUID
/**
* Zugriff auf die Dokumenthistorie.
*
* Bewusst **gezielt** statt „lade alles und filtere in Kotlin": Mit dem
* Live-Editing entstehen hunderte Versionen je Dokument, und jede trägt den
* vollen Text (D76).
*/
interface DocumentHistoryRepository {
fun append(entry: DocumentHistoryEntry)
/** Alle Einträge zu einem Dokument, älteste zuerst. */
fun findByDocumentId(documentId: UUID): List<DocumentHistoryEntry>
/** Gibt es zu dieser UUID überhaupt Historie? Auch für gelöschte Dokumente wahr. */
fun exists(documentId: UUID): Boolean
/** Genau eine Version oder `null`, wenn sie nie existierte oder verdichtet wurde. */
fun findVersion(documentId: UUID, version: Long): DocumentHistoryEntry?
/** Der jüngste Eintrag, gleich welchen Typs (bei gelöschten Dokumenten der Tombstone). */
fun findLatest(documentId: UUID): DocumentHistoryEntry?
/** Der älteste Eintrag die Anlage des Dokuments, immer ein Meilenstein. */
fun findOldest(documentId: UUID): DocumentHistoryEntry?
/** Höchste vergebene Versionsnummer, auch wenn deren Eintrag verdichtet wurde. */
fun maxVersion(documentId: UUID): Long?
/** Die nutzersichtbare Historie, älteste zuerst. */
fun findMilestones(documentId: UUID): List<DocumentHistoryEntry>
/**
* Erhebt eine Sync-Version nachträglich zum Meilenstein sie war die
* letzte vor einer Schreibpause.
*/
fun promoteToMilestone(documentId: UUID, version: Long)
/**
* Verdichtet: entfernt Sync-Versionen dieses Dokuments, die älter als
* [olderThan] sind. Meilensteine bleiben. Liefert die Zahl der entfernten
* Einträge.
*/
fun compact(documentId: UUID, olderThan: OffsetDateTime): Int
fun clear()
}
@@ -8,6 +8,7 @@ import de.werkbaum.repository.DocumentRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Clock
import java.time.Duration
import java.time.OffsetDateTime
import java.util.UUID
@@ -17,6 +18,7 @@ class DocumentService(
private val repository: DocumentRepository,
private val historyRepository: DocumentHistoryRepository,
private val clock: Clock,
private val properties: LiveEditingProperties,
) {
fun findAll(): List<Document> = repository.findAll()
@@ -39,7 +41,16 @@ class DocumentService(
return document
}
fun update(id: UUID, title: String, content: String): Document {
/**
* Ersetzt Titel und Inhalt vollständig.
*
* [milestone] `false` schreibt eine **Sync-Version** gedacht für den
* getakteten Strom des Live-Editings (D76), der sonst hunderte
* nutzersichtbare Stände je Sitzung erzeugte. Der Vollersatz über die API
* ist dagegen eine bewusste Handlung (Import, Reparatur) und bleibt
* Meilenstein.
*/
fun update(id: UUID, title: String, content: String, milestone: Boolean = true): Document {
val existing = findById(id)
val updated = existing.copy(
title = title,
@@ -48,7 +59,7 @@ class DocumentService(
updatedAt = OffsetDateTime.now(clock),
)
repository.save(updated)
recordHistory(updated, ChangeType.UPDATED)
recordHistory(updated, ChangeType.UPDATED, milestone)
return updated
}
@@ -66,26 +77,42 @@ class DocumentService(
}
/**
* Historie eines Dokuments funktioniert auch für bereits gelöschte
* Dokumente. 404 nur, wenn die UUID gänzlich unbekannt ist.
* Die **nutzersichtbare** Historie: alle Meilensteine, älteste zuerst,
* dazu immer der jüngste Stand. Sync-Versionen bleiben draußen sie
* tragen das Protokoll, nicht die Erzählung (D76).
*
* Funktioniert auch für bereits gelöschte Dokumente; 404 nur, wenn die
* UUID gänzlich unbekannt ist.
*/
fun history(id: UUID): List<DocumentHistoryEntry> {
val entries = historyRepository.findByDocumentId(id)
if (entries.isEmpty()) throw DocumentNotFoundException(id)
return entries
if (!historyRepository.exists(id)) throw DocumentNotFoundException(id)
val milestones = historyRepository.findMilestones(id)
// Die letzte Version einer noch laufenden Schreibphase ist noch kein
// Meilenstein sichtbar sein muss sie trotzdem.
val latest = historyRepository.findLatest(id)
return if (latest != null && milestones.none { it.version == latest.version }) {
milestones + latest
} else {
milestones
}
}
/**
* Stellt ein Dokument unter derselben UUID wieder her.
*
* - Ohne [targetVersion]: letzter inhaltlicher Stand vor dem Löschen.
* - Ohne [targetVersion]: letzter Stand vor dem Löschen ([ChangeType.RESTORED]).
* Existiert das Dokument noch, gibt es einen Konflikt (409).
* - Mit [targetVersion]: Inhalt dieser Version wird als neue Version
* übernommen funktioniert auch als Rollback für existierende Dokumente.
* übernommen. Bei einem lebenden Dokument ist das ein Rückfall
* ([ChangeType.ROLLED_BACK]), kein Wiederherstellen der Client hatte
* nie eine Sperre.
*
* Eine verdichtete Sync-Version ist nicht mehr anzusteuern (404). Das ist
* die Zwei-Ebenen-Regel im Betrieb: Angeboten werden Meilensteine, und die
* bleiben.
*/
fun restore(id: UUID, targetVersion: Long? = null): Document {
val entries = historyRepository.findByDocumentId(id)
if (entries.isEmpty()) throw DocumentNotFoundException(id)
if (!historyRepository.exists(id)) throw DocumentNotFoundException(id)
val existing = repository.findById(id)
if (existing != null && targetVersion == null) {
@@ -95,28 +122,59 @@ class DocumentService(
}
val snapshot = if (targetVersion != null) {
entries.lastOrNull { it.version == targetVersion && it.changeType != ChangeType.DELETED }
historyRepository.findVersion(id, targetVersion)
?.takeIf { it.changeType != ChangeType.DELETED }
?: throw DocumentNotFoundException(id)
} else {
entries.last { it.changeType != ChangeType.DELETED }
// Der Tombstone trägt den letzten Stand er ist die verlässliche
// Quelle, auch wenn die Version davor längst verdichtet wurde.
historyRepository.findLatest(id) ?: throw DocumentNotFoundException(id)
}
val now = OffsetDateTime.now(clock)
val lastVersion = maxOf(entries.maxOf { it.version }, existing?.version ?: 0)
val lastVersion = maxOf(historyRepository.maxVersion(id) ?: 0, existing?.version ?: 0)
val restored = Document(
id = id,
title = snapshot.title,
content = snapshot.content,
version = lastVersion + 1,
createdAt = existing?.createdAt ?: entries.first().timestamp,
createdAt = existing?.createdAt
?: historyRepository.findOldest(id)?.timestamp
?: now,
updatedAt = now,
)
repository.save(restored)
recordHistory(restored, ChangeType.RESTORED)
recordHistory(
restored,
if (existing != null) ChangeType.ROLLED_BACK else ChangeType.RESTORED,
)
return restored
}
private fun recordHistory(document: Document, changeType: ChangeType) {
/**
* Schreibt einen Historieneintrag und hält dabei die zwei Ebenen instand:
*
* 1. War die vorige Version eine Sync-Version und liegt sie länger als
* [LiveEditingProperties.milestonePause] zurück, war sie die **letzte
* vor einer Schreibpause** und wird nachträglich Meilenstein. So
* braucht es keinen Zeitgeber die nächste Änderung stellt fest, dass
* eine Pause war.
* 2. Strukturelle Änderungen sind immer Meilensteine.
* 3. Danach wird verdichtet: Sync-Versionen jenseits der
* Aufbewahrungsfrist entfallen.
*/
private fun recordHistory(
document: Document,
changeType: ChangeType,
milestone: Boolean = true,
) {
val previous = historyRepository.findLatest(document.id)
if (previous != null && !previous.milestone &&
Duration.between(previous.timestamp, document.updatedAt) >= properties.milestonePause
) {
historyRepository.promoteToMilestone(document.id, previous.version)
}
historyRepository.append(
DocumentHistoryEntry(
documentId = document.id,
@@ -125,7 +183,17 @@ class DocumentService(
content = document.content,
changeType = changeType,
timestamp = document.updatedAt,
milestone = milestone || changeType.isStructural,
)
)
historyRepository.compact(
document.id,
document.updatedAt.minus(properties.syncRetention),
)
}
/** Anlegen, Löschen, Wiederherstellen und Rückfall sind nie bloß Sync-Versionen. */
private val ChangeType.isStructural: Boolean
get() = this != ChangeType.UPDATED
}
@@ -0,0 +1,25 @@
package de.werkbaum.service
import org.springframework.boot.context.properties.ConfigurationProperties
import java.time.Duration
/**
* Stellschrauben des Live-Editings (D76). Bewusst konfigurierbar: Die Werte
* sind gesetzt, nicht hergeleitet, und werden nach Erfahrung justiert wie
* die Schwellen im Frontend (D64, D71).
*/
@ConfigurationProperties(prefix = "werkbaum.live-editing")
data class LiveEditingProperties(
/**
* Schreibpause, nach der die letzte Version zum Meilenstein wird. Kürzer
* heißt mehr nutzersichtbare Stände, länger heißt gröbere Historie.
*/
val milestonePause: Duration = Duration.ofSeconds(30),
/**
* Wie lange Sync-Versionen aufgehoben werden. Danach beantwortet der Feed
* ein so altes `since` mit Volltext statt mit einem Diff.
*/
val syncRetention: Duration = Duration.ofHours(1),
)
@@ -18,5 +18,13 @@ spring:
liquibase:
change-log: classpath:db/changelog/db.changelog-master.sql
werkbaum:
live-editing:
# Schreibpause, nach der die letzte Version zum Meilenstein wird.
milestone-pause: 30s
# Danach wird eine Sync-Version verdichtet; der Feed antwortet auf ein so
# altes "since" dann mit Volltext statt mit einem Diff.
sync-retention: 1h
server:
port: 8080
@@ -26,3 +26,13 @@ CREATE TABLE document_history (
--changeset editor:003-index-document-history
CREATE INDEX idx_document_history_document_id ON document_history (document_id);
--rollback DROP INDEX idx_document_history_document_id;
--changeset editor:004-history-milestone
-- Zwei Ebenen (D76): Meilensteine sind die nutzersichtbare Historie und
-- bleiben; Sync-Versionen tragen die Diffs des Live-Editings und werden nach
-- einer Weile verdichtet. Bestand ist Meilenstein - er stammt aus der Zeit
-- ohne Live-Editing und ist durchweg nutzersichtbar.
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;
+14 -4
View File
@@ -127,11 +127,13 @@ paths:
operationId: getDocumentHistory
summary: Historie eines Dokuments abrufen
description: >
Liefert alle Versionen eines Dokuments in chronologischer Reihenfolge.
Die Historie bleibt auch nach dem Loeschen des Dokuments erhalten.
Liefert die nutzersichtbaren Staende (Meilensteine) in chronologischer
Reihenfolge, dazu immer den juengsten Stand. Kurzlebige Sync-Versionen
des Live-Editings bleiben aussen vor. Die Historie ueberlebt das
Loeschen des Dokuments.
responses:
"200":
description: Historie des Dokuments (aelteste zuerst)
description: Meilensteine des Dokuments (aelteste zuerst)
content:
application/json:
schema:
@@ -253,6 +255,10 @@ components:
description: Optional; wird spaeter fuer Optimistic Locking ausgewertet.
DocumentHistoryEntry:
description: >
Ein Stand der nutzersichtbaren Historie. Sync-Versionen des
Live-Editings erscheinen hier nicht - sie tragen das Protokoll, nicht
die Erzaehlung.
type: object
required: [documentId, version, title, content, changeType, timestamp]
properties:
@@ -268,7 +274,11 @@ components:
type: string
changeType:
type: string
enum: [CREATED, UPDATED, DELETED, RESTORED]
description: >
RESTORED heisst: ein geloeschtes Dokument ist wieder da.
ROLLED_BACK ist der Rueckfall eines lebenden Dokuments auf eine
aeltere Version.
enum: [CREATED, UPDATED, DELETED, RESTORED, ROLLED_BACK]
timestamp:
type: string
format: date-time
@@ -159,6 +159,16 @@ class DocumentStepDefinitions {
.returnResult(String::class.java)
}
@Wenn("ich dieses Dokument auf Version {long} zurücksetze")
fun `ich setze dieses Dokument zurueck`(version: Long) {
lastResponse = client.post()
.uri("/api/v1/documents/$currentDocumentId/restore")
.contentType(MediaType.APPLICATION_JSON)
.body("""{"version":$version}""")
.exchange()
.returnResult(String::class.java)
}
@Und("die Antwort enthält {int} Historieneinträge")
fun `die Antwort enthaelt n Historieneintraege`(anzahl: Int) {
val count = Regex("\"changeType\"").findAll(body()).count()
@@ -8,26 +8,37 @@ import de.werkbaum.repository.DocumentRepository
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.CapturingSlot
import io.mockk.slot
import io.mockk.verify
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.UUID
class DocumentServiceTest {
private val fixedClock: Clock =
Clock.fixed(Instant.parse("2026-01-01T12:00:00Z"), ZoneOffset.UTC)
/** Uhr, die sich im Test weiterstellen lässt für die Schreibpause-Regel. */
private class TestClock(var moment: Instant) : Clock() {
override fun getZone(): ZoneId = ZoneOffset.UTC
override fun withZone(zone: ZoneId?): Clock = this
override fun instant(): Instant = moment
}
private val clock = TestClock(Instant.parse("2026-01-01T12:00:00Z"))
private val properties = LiveEditingProperties(
milestonePause = Duration.ofSeconds(30),
syncRetention = Duration.ofHours(1),
)
private val repository = mockk<DocumentRepository>()
private val historyRepository = mockk<DocumentHistoryRepository>(relaxUnitFun = true)
private val service = DocumentService(repository, historyRepository, fixedClock)
private val historyRepository = mockk<DocumentHistoryRepository>(relaxed = true)
private val service = DocumentService(repository, historyRepository, clock, properties)
private fun sampleDocument(
id: UUID = UUID.randomUUID(),
@@ -37,8 +48,8 @@ class DocumentServiceTest {
title = "Titel",
content = "Inhalt",
version = version,
createdAt = OffsetDateTime.now(fixedClock),
updatedAt = OffsetDateTime.now(fixedClock),
createdAt = OffsetDateTime.now(clock),
updatedAt = OffsetDateTime.now(clock),
)
private fun historyEntry(
@@ -47,28 +58,40 @@ class DocumentServiceTest {
changeType: ChangeType,
title: String = "Titel v$version",
content: String = "Inhalt v$version",
milestone: Boolean = true,
timestamp: OffsetDateTime = OffsetDateTime.now(clock),
) = DocumentHistoryEntry(
documentId = id,
version = version,
title = title,
content = content,
changeType = changeType,
timestamp = OffsetDateTime.now(fixedClock),
timestamp = timestamp,
milestone = milestone,
)
private fun captureAppended(): CapturingSlot<DocumentHistoryEntry> {
val slot = slot<DocumentHistoryEntry>()
every { historyRepository.append(capture(slot)) } returns Unit
return slot
}
// -----------------------------------------------------------------------
// Anlegen, Ändern, Löschen
// -----------------------------------------------------------------------
@Test
fun `create legt Dokument an und schreibt CREATED-Historieneintrag`() {
val saved = slot<Document>()
every { repository.save(capture(saved)) } answers { saved.captured }
val historyEntry = slot<DocumentHistoryEntry>()
every { historyRepository.append(capture(historyEntry)) } just runs
val entry = captureAppended()
val result = service.create(title = "Notizen", content = "Hallo")
result.version shouldBe 1
historyEntry.captured.changeType shouldBe ChangeType.CREATED
historyEntry.captured.documentId shouldBe result.id
historyEntry.captured.content shouldBe "Hallo"
entry.captured.changeType shouldBe ChangeType.CREATED
entry.captured.documentId shouldBe result.id
entry.captured.content shouldBe "Hallo"
}
@Test
@@ -77,14 +100,13 @@ class DocumentServiceTest {
every { repository.findById(doc.id) } returns doc
val saved = slot<Document>()
every { repository.save(capture(saved)) } answers { saved.captured }
val historyEntry = slot<DocumentHistoryEntry>()
every { historyRepository.append(capture(historyEntry)) } just runs
val entry = captureAppended()
val result = service.update(doc.id, title = "Neu", content = "Neuer Inhalt")
result.version shouldBe 4
historyEntry.captured.changeType shouldBe ChangeType.UPDATED
historyEntry.captured.version shouldBe 4
entry.captured.changeType shouldBe ChangeType.UPDATED
entry.captured.version shouldBe 4
}
@Test
@@ -92,14 +114,13 @@ class DocumentServiceTest {
val doc = sampleDocument(version = 2)
every { repository.findById(doc.id) } returns doc
every { repository.deleteById(doc.id) } returns true
val historyEntry = slot<DocumentHistoryEntry>()
every { historyRepository.append(capture(historyEntry)) } just runs
val entry = captureAppended()
service.delete(doc.id)
verify(exactly = 1) { repository.deleteById(doc.id) }
historyEntry.captured.changeType shouldBe ChangeType.DELETED
historyEntry.captured.version shouldBe 3
entry.captured.changeType shouldBe ChangeType.DELETED
entry.captured.version shouldBe 3
}
@Test
@@ -110,39 +131,156 @@ class DocumentServiceTest {
shouldThrow<DocumentNotFoundException> { service.delete(id) }
}
// -----------------------------------------------------------------------
// Zwei Ebenen: Meilensteine und Sync-Versionen
// -----------------------------------------------------------------------
@Test
fun `history liefert Eintraege auch ohne existierendes Dokument`() {
fun `ein gewoehnliches Update ist ein Meilenstein`() {
val doc = sampleDocument(version = 1)
every { repository.findById(doc.id) } returns doc
every { repository.save(any()) } answers { firstArg() }
val entry = captureAppended()
service.update(doc.id, "T", "C")
entry.captured.milestone shouldBe true
}
@Test
fun `ein getaktetes Update schreibt eine Sync-Version`() {
val doc = sampleDocument(version = 1)
every { repository.findById(doc.id) } returns doc
every { repository.save(any()) } answers { firstArg() }
val entry = captureAppended()
service.update(doc.id, "T", "C", milestone = false)
entry.captured.milestone shouldBe false
}
@Test
fun `strukturelle Aenderungen sind immer Meilensteine`() {
val doc = sampleDocument(version = 1)
every { repository.findById(doc.id) } returns doc
every { repository.deleteById(doc.id) } returns true
val entry = captureAppended()
service.delete(doc.id)
entry.captured.milestone shouldBe true
}
@Test
fun `die letzte Version vor einer Schreibpause wird nachtraeglich Meilenstein`() {
val doc = sampleDocument(version = 5)
every { repository.findById(doc.id) } returns doc
every { repository.save(any()) } answers { firstArg() }
every { historyRepository.findLatest(doc.id) } returns
historyEntry(doc.id, 5, ChangeType.UPDATED, milestone = false)
clock.moment = clock.moment.plusSeconds(31)
service.update(doc.id, "T", "C", milestone = false)
verify(exactly = 1) { historyRepository.promoteToMilestone(doc.id, 5) }
}
@Test
fun `innerhalb der Schreibpause wird nichts befoerdert`() {
val doc = sampleDocument(version = 5)
every { repository.findById(doc.id) } returns doc
every { repository.save(any()) } answers { firstArg() }
every { historyRepository.findLatest(doc.id) } returns
historyEntry(doc.id, 5, ChangeType.UPDATED, milestone = false)
clock.moment = clock.moment.plusSeconds(29)
service.update(doc.id, "T", "C", milestone = false)
verify(exactly = 0) { historyRepository.promoteToMilestone(any(), any()) }
}
@Test
fun `ein Meilenstein wird nicht noch einmal befoerdert`() {
val doc = sampleDocument(version = 5)
every { repository.findById(doc.id) } returns doc
every { repository.save(any()) } answers { firstArg() }
every { historyRepository.findLatest(doc.id) } returns
historyEntry(doc.id, 5, ChangeType.UPDATED, milestone = true)
clock.moment = clock.moment.plusSeconds(3600)
service.update(doc.id, "T", "C", milestone = false)
verify(exactly = 0) { historyRepository.promoteToMilestone(any(), any()) }
}
@Test
fun `nach jeder Aenderung wird jenseits der Aufbewahrungsfrist verdichtet`() {
val doc = sampleDocument(version = 1)
every { repository.findById(doc.id) } returns doc
every { repository.save(any()) } answers { firstArg() }
service.update(doc.id, "T", "C")
verify(exactly = 1) {
historyRepository.compact(doc.id, OffsetDateTime.now(clock).minusHours(1))
}
}
// -----------------------------------------------------------------------
// Historie
// -----------------------------------------------------------------------
@Test
fun `history liefert die Meilensteine, auch ohne existierendes Dokument`() {
val id = UUID.randomUUID()
val entries = listOf(
val meilensteine = listOf(
historyEntry(id, 1, ChangeType.CREATED),
historyEntry(id, 2, ChangeType.DELETED),
)
every { historyRepository.findByDocumentId(id) } returns entries
every { historyRepository.exists(id) } returns true
every { historyRepository.findMilestones(id) } returns meilensteine
every { historyRepository.findLatest(id) } returns meilensteine.last()
service.history(id) shouldBe entries
service.history(id) shouldBe meilensteine
}
@Test
fun `history zeigt den juengsten Stand auch als noch nicht befoerderte Sync-Version`() {
// Sonst fehlte in einer laufenden Schreibphase ausgerechnet der aktuelle Stand.
val id = UUID.randomUUID()
val meilenstein = historyEntry(id, 1, ChangeType.CREATED)
val laufend = historyEntry(id, 7, ChangeType.UPDATED, milestone = false)
every { historyRepository.exists(id) } returns true
every { historyRepository.findMilestones(id) } returns listOf(meilenstein)
every { historyRepository.findLatest(id) } returns laufend
service.history(id) shouldBe listOf(meilenstein, laufend)
}
@Test
fun `history wirft Exception bei gaenzlich unbekannter ID`() {
val id = UUID.randomUUID()
every { historyRepository.findByDocumentId(id) } returns emptyList()
every { historyRepository.exists(id) } returns false
shouldThrow<DocumentNotFoundException> { service.history(id) }
}
// -----------------------------------------------------------------------
// Wiederherstellen und Rückfall
// -----------------------------------------------------------------------
@Test
fun `restore stellt geloeschtes Dokument mit letztem Stand wieder her`() {
fun `restore stellt geloeschtes Dokument aus dem Tombstone wieder her`() {
val id = UUID.randomUUID()
every { historyRepository.findByDocumentId(id) } returns listOf(
historyEntry(id, 1, ChangeType.CREATED),
historyEntry(id, 2, ChangeType.UPDATED),
historyEntry(id, 3, ChangeType.DELETED),
)
every { historyRepository.exists(id) } returns true
every { historyRepository.findLatest(id) } returns
historyEntry(id, 3, ChangeType.DELETED, title = "Titel v2", content = "Inhalt v2")
every { historyRepository.maxVersion(id) } returns 3
every { historyRepository.findOldest(id) } returns historyEntry(id, 1, ChangeType.CREATED)
every { repository.findById(id) } returns null
val saved = slot<Document>()
every { repository.save(capture(saved)) } answers { saved.captured }
val historyEntry = slot<DocumentHistoryEntry>()
every { historyRepository.append(capture(historyEntry)) } just runs
val entry = captureAppended()
val result = service.restore(id)
@@ -150,35 +288,43 @@ class DocumentServiceTest {
result.title shouldBe "Titel v2"
result.content shouldBe "Inhalt v2"
result.version shouldBe 4
historyEntry.captured.changeType shouldBe ChangeType.RESTORED
entry.captured.changeType shouldBe ChangeType.RESTORED
}
@Test
fun `restore mit Zielversion funktioniert als Rollback fuer existierendes Dokument`() {
fun `restore mit Zielversion ist bei lebendem Dokument ein Rueckfall`() {
val id = UUID.randomUUID()
val existing = sampleDocument(id = id, version = 3)
every { historyRepository.findByDocumentId(id) } returns listOf(
historyEntry(id, 1, ChangeType.CREATED),
historyEntry(id, 2, ChangeType.UPDATED),
historyEntry(id, 3, ChangeType.UPDATED),
)
every { historyRepository.exists(id) } returns true
every { historyRepository.findVersion(id, 1) } returns
historyEntry(id, 1, ChangeType.CREATED)
every { historyRepository.maxVersion(id) } returns 3
every { repository.findById(id) } returns existing
val saved = slot<Document>()
every { repository.save(capture(saved)) } answers { saved.captured }
every { historyRepository.append(any()) } just runs
every { repository.save(any()) } answers { firstArg() }
val entry = captureAppended()
val result = service.restore(id, targetVersion = 1)
result.title shouldBe "Titel v1"
result.version shouldBe 4
// RESTORED hiesse "die Sperre ist aufgehoben" - hier gab es nie eine.
entry.captured.changeType shouldBe ChangeType.ROLLED_BACK
}
@Test
fun `eine verdichtete Zielversion ist nicht mehr anzusteuern`() {
val id = UUID.randomUUID()
every { historyRepository.exists(id) } returns true
every { historyRepository.findVersion(id, 42) } returns null
every { repository.findById(id) } returns sampleDocument(id = id)
shouldThrow<DocumentNotFoundException> { service.restore(id, targetVersion = 42) }
}
@Test
fun `restore ohne Zielversion wirft Konflikt wenn Dokument noch existiert`() {
val id = UUID.randomUUID()
every { historyRepository.findByDocumentId(id) } returns listOf(
historyEntry(id, 1, ChangeType.CREATED),
)
every { historyRepository.exists(id) } returns true
every { repository.findById(id) } returns sampleDocument(id = id)
shouldThrow<DocumentConflictException> { service.restore(id) }
@@ -187,11 +333,13 @@ class DocumentServiceTest {
@Test
fun `restore wirft Exception bei unbekannter ID`() {
val id = UUID.randomUUID()
every { historyRepository.findByDocumentId(id) } returns emptyList()
every { historyRepository.exists(id) } returns false
shouldThrow<DocumentNotFoundException> { service.restore(id) }
}
// -----------------------------------------------------------------------
@Test
fun `findById wirft Exception bei unbekannter ID`() {
val id = UUID.randomUUID()
@@ -64,6 +64,15 @@ Funktionalität: Dokumente verwalten
Und die Antwort enthält den Titel "Vertrag"
Und das Dokument ist wieder abrufbar
Szenario: Ein Rückfall auf eine alte Version ist kein Wiederherstellen
Angenommen es existiert ein Dokument mit dem Titel "Satzung"
Wenn ich den Titel dieses Dokuments auf "Satzung v2" ändere
Und ich dieses Dokument auf Version 1 zurücksetze
Dann erhalte ich den Status 200
Und die Antwort enthält den Titel "Satzung"
Wenn ich die Historie dieses Dokuments abrufe
Dann die Antwort enthält den Änderungstyp "ROLLED_BACK"
Szenario: Wiederherstellen ohne Historie schlägt fehl
Wenn ich ein Dokument mit einer unbekannten ID abrufe
Dann erhalte ich den Status 404