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, **Stand:** Gerüst steht — Dokumenten-CRUD mit Historie und Wiederherstellung,
API-First aus `src/main/resources/openapi/api.yaml`, H2 mit Liquibase. API-First aus `src/main/resources/openapi/api.yaml`, H2 mit Liquibase.
Kommandos in README.md hier. Live-Editing ist entschieden (D76), aber noch Kommandos in README.md hier. Live-Editing (D76,
nicht gebaut: `docs/live-editing-proposal.md`. `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 ## Konventionen
- Kotlin, **Spring Boot 4**, Gradle (Kotlin DSL), JDK 21. - Kotlin, **Spring Boot 4**, Gradle (Kotlin DSL), JDK 21.
+31 -15
View File
@@ -61,16 +61,31 @@ werden.
## Historie & Wiederherstellung ## Historie & Wiederherstellung
- Jede Änderung (CREATED, UPDATED, DELETED, RESTORED) wird als Snapshot in - Jede Änderung wird als Snapshot in einer vom Dokument getrennten Historie
einer vom Dokument getrennten Historie protokolliert sie **überlebt ein protokolliert sie **überlebt ein DELETE**.
DELETE**. - **Zwei Ebenen** (D76): **Meilensteine** sind die nutzersichtbare Historie und
- `GET /api/v1/documents/{uuid}/history` liefert alle Einträge (älteste bleiben; **Sync-Versionen** tragen die Diffs des Live-Editings, sind
zuerst); Identifier ist die UUID, wie bei GET (der Titel ist nicht eindeutig). 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 - `POST /api/v1/documents/{uuid}/restore` stellt ein gelöschtes Dokument unter
derselben UUID wieder her (letzter Stand vor dem Löschen). Mit optionalem derselben UUID wieder her (`RESTORED`, letzter Stand aus dem Tombstone). Mit
Body `{"version": n}` wird eine bestimmte Version wiederhergestellt das optionalem Body `{"version": n}` wird eine bestimmte Version übernommen bei
funktioniert auch als Rollback für noch existierende Dokumente; ohne einem noch lebenden Dokument ist das ein Rückfall (`ROLLED_BACK`), kein
Zielversion antwortet der Server bei existierendem Dokument mit 409. 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 ## Vorbereitete Erweiterungen
@@ -81,12 +96,13 @@ werden.
Spec; die Behavior-Tests erhalten dann einen Auth-Schritt Spec; die Behavior-Tests erhalten dann einen Auth-Schritt
(„Angenommen ich bin als … angemeldet"). („Angenommen ich bin als … angemeldet").
**Live-Editing** **Live-Editing** (Konzept: `docs/live-editing-proposal.md`, Entscheidung: D76)
- Jedes Dokument trägt eine `version`, die bei jedem Update inkrementiert - **Gebaut:** das Zeilen-Diff als reine Funktionen (`de.werkbaum.diff`
wird Basis für Optimistic Locking (HTTP 409 ist in der Spec bereits Anwenden, Berechnen, Rebasen, Prüfsumme) und die zweistufige Historie.
reserviert) und für Delta-Synchronisation über WebSocket/STOMP. - **Offen:** `PATCH /content` samt Rebase und Idempotenz, der Änderungsfeed per
- `DocumentUpdateRequest.expectedVersion` ist bereits im Vertrag vorgesehen, Long Polling, Master-Passwort für `GET /documents`, Client-Anpassung.
wird aber noch nicht ausgewertet. - `DocumentUpdateRequest.expectedVersion` ist im Vertrag vorgesehen, wird aber
noch nicht ausgewertet.
**Clientseitige Verschlüsselung** **Clientseitige Verschlüsselung**
- `content` ist ein opaker String, den der Server nie interpretiert. Der - `content` ist ein opaker String, den der Server nie interpretiert. Der
@@ -1,9 +1,11 @@
package de.werkbaum package de.werkbaum
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication import org.springframework.boot.runApplication
@SpringBootApplication @SpringBootApplication
@ConfigurationPropertiesScan
class EditorBackendApplication class EditorBackendApplication
fun main(args: Array<String>) { fun main(args: Array<String>) {
@@ -3,12 +3,33 @@ package de.werkbaum.domain
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.UUID 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 * Ein Eintrag der Dokumenthistorie. Die Historie wird getrennt vom Dokument
* gespeichert und überlebt daher ein DELETE Grundlage für die * 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( data class DocumentHistoryEntry(
val documentId: UUID, val documentId: UUID,
@@ -17,4 +38,5 @@ data class DocumentHistoryEntry(
val content: String, val content: String,
val changeType: ChangeType, val changeType: ChangeType,
val timestamp: OffsetDateTime, val timestamp: OffsetDateTime,
val milestone: Boolean = true,
) )
@@ -38,6 +38,10 @@ class DocumentHistoryEntity(
@Column(name = "change_time", nullable = false) @Column(name = "change_time", nullable = false)
val changeTime: OffsetDateTime, val changeTime: OffsetDateTime,
/** Meilenstein (nutzersichtbar, bleibt) oder Sync-Version (wird verdichtet) D76. */
@Column(nullable = false)
var milestone: Boolean = true,
) { ) {
fun toDomain() = DocumentHistoryEntry( fun toDomain() = DocumentHistoryEntry(
documentId = documentId, documentId = documentId,
@@ -46,6 +50,7 @@ class DocumentHistoryEntity(
content = content, content = content,
changeType = changeType, changeType = changeType,
timestamp = changeTime, timestamp = changeTime,
milestone = milestone,
) )
companion object { companion object {
@@ -56,6 +61,7 @@ class DocumentHistoryEntity(
content = entry.content, content = entry.content,
changeType = entry.changeType, changeType = entry.changeType,
changeTime = entry.timestamp, changeTime = entry.timestamp,
milestone = entry.milestone,
) )
} }
} }
@@ -3,6 +3,7 @@ package de.werkbaum.persistence
import de.werkbaum.domain.DocumentHistoryEntry import de.werkbaum.domain.DocumentHistoryEntry
import de.werkbaum.repository.DocumentHistoryRepository import de.werkbaum.repository.DocumentHistoryRepository
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.time.OffsetDateTime
import java.util.UUID import java.util.UUID
@Repository @Repository
@@ -14,8 +15,28 @@ class JpaDocumentHistoryRepository(
jpa.save(DocumentHistoryEntity.fromDomain(entry)) jpa.save(DocumentHistoryEntity.fromDomain(entry))
} }
override fun findByDocumentId(documentId: UUID): List<DocumentHistoryEntry> = override fun exists(documentId: UUID): Boolean = jpa.existsByDocumentId(documentId)
jpa.findByDocumentIdOrderByIdAsc(documentId).map { it.toDomain() }
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() override fun clear() = jpa.deleteAll()
} }
@@ -1,10 +1,53 @@
package de.werkbaum.persistence package de.werkbaum.persistence
import org.springframework.data.jpa.repository.JpaRepository 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 import java.util.UUID
interface DocumentJpaRepository : JpaRepository<DocumentEntity, UUID> interface DocumentJpaRepository : JpaRepository<DocumentEntity, UUID>
interface DocumentHistoryJpaRepository : JpaRepository<DocumentHistoryEntity, Long> { 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 package de.werkbaum.repository
import de.werkbaum.domain.DocumentHistoryEntry import de.werkbaum.domain.DocumentHistoryEntry
import java.time.OffsetDateTime
import java.util.UUID 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 { interface DocumentHistoryRepository {
fun append(entry: DocumentHistoryEntry) fun append(entry: DocumentHistoryEntry)
/** Alle Einträge zu einem Dokument, älteste zuerst. */ /** Gibt es zu dieser UUID überhaupt Historie? Auch für gelöschte Dokumente wahr. */
fun findByDocumentId(documentId: UUID): List<DocumentHistoryEntry> 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() fun clear()
} }
@@ -8,6 +8,7 @@ import de.werkbaum.repository.DocumentRepository
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.Clock import java.time.Clock
import java.time.Duration
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.UUID import java.util.UUID
@@ -17,6 +18,7 @@ class DocumentService(
private val repository: DocumentRepository, private val repository: DocumentRepository,
private val historyRepository: DocumentHistoryRepository, private val historyRepository: DocumentHistoryRepository,
private val clock: Clock, private val clock: Clock,
private val properties: LiveEditingProperties,
) { ) {
fun findAll(): List<Document> = repository.findAll() fun findAll(): List<Document> = repository.findAll()
@@ -39,7 +41,16 @@ class DocumentService(
return document 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 existing = findById(id)
val updated = existing.copy( val updated = existing.copy(
title = title, title = title,
@@ -48,7 +59,7 @@ class DocumentService(
updatedAt = OffsetDateTime.now(clock), updatedAt = OffsetDateTime.now(clock),
) )
repository.save(updated) repository.save(updated)
recordHistory(updated, ChangeType.UPDATED) recordHistory(updated, ChangeType.UPDATED, milestone)
return updated return updated
} }
@@ -66,26 +77,42 @@ class DocumentService(
} }
/** /**
* Historie eines Dokuments funktioniert auch für bereits gelöschte * Die **nutzersichtbare** Historie: alle Meilensteine, älteste zuerst,
* Dokumente. 404 nur, wenn die UUID gänzlich unbekannt ist. * 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> { fun history(id: UUID): List<DocumentHistoryEntry> {
val entries = historyRepository.findByDocumentId(id) if (!historyRepository.exists(id)) throw DocumentNotFoundException(id)
if (entries.isEmpty()) throw DocumentNotFoundException(id) val milestones = historyRepository.findMilestones(id)
return entries // 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. * 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). * Existiert das Dokument noch, gibt es einen Konflikt (409).
* - Mit [targetVersion]: Inhalt dieser Version wird als neue Version * - 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 { fun restore(id: UUID, targetVersion: Long? = null): Document {
val entries = historyRepository.findByDocumentId(id) if (!historyRepository.exists(id)) throw DocumentNotFoundException(id)
if (entries.isEmpty()) throw DocumentNotFoundException(id)
val existing = repository.findById(id) val existing = repository.findById(id)
if (existing != null && targetVersion == null) { if (existing != null && targetVersion == null) {
@@ -95,28 +122,59 @@ class DocumentService(
} }
val snapshot = if (targetVersion != null) { 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) ?: throw DocumentNotFoundException(id)
} else { } 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 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( val restored = Document(
id = id, id = id,
title = snapshot.title, title = snapshot.title,
content = snapshot.content, content = snapshot.content,
version = lastVersion + 1, version = lastVersion + 1,
createdAt = existing?.createdAt ?: entries.first().timestamp, createdAt = existing?.createdAt
?: historyRepository.findOldest(id)?.timestamp
?: now,
updatedAt = now, updatedAt = now,
) )
repository.save(restored) repository.save(restored)
recordHistory(restored, ChangeType.RESTORED) recordHistory(
restored,
if (existing != null) ChangeType.ROLLED_BACK else ChangeType.RESTORED,
)
return 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( historyRepository.append(
DocumentHistoryEntry( DocumentHistoryEntry(
documentId = document.id, documentId = document.id,
@@ -125,7 +183,17 @@ class DocumentService(
content = document.content, content = document.content,
changeType = changeType, changeType = changeType,
timestamp = document.updatedAt, 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: liquibase:
change-log: classpath:db/changelog/db.changelog-master.sql 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: server:
port: 8080 port: 8080
@@ -26,3 +26,13 @@ CREATE TABLE document_history (
--changeset editor:003-index-document-history --changeset editor:003-index-document-history
CREATE INDEX idx_document_history_document_id ON document_history (document_id); CREATE INDEX idx_document_history_document_id ON document_history (document_id);
--rollback DROP INDEX idx_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 operationId: getDocumentHistory
summary: Historie eines Dokuments abrufen summary: Historie eines Dokuments abrufen
description: > description: >
Liefert alle Versionen eines Dokuments in chronologischer Reihenfolge. Liefert die nutzersichtbaren Staende (Meilensteine) in chronologischer
Die Historie bleibt auch nach dem Loeschen des Dokuments erhalten. Reihenfolge, dazu immer den juengsten Stand. Kurzlebige Sync-Versionen
des Live-Editings bleiben aussen vor. Die Historie ueberlebt das
Loeschen des Dokuments.
responses: responses:
"200": "200":
description: Historie des Dokuments (aelteste zuerst) description: Meilensteine des Dokuments (aelteste zuerst)
content: content:
application/json: application/json:
schema: schema:
@@ -253,6 +255,10 @@ components:
description: Optional; wird spaeter fuer Optimistic Locking ausgewertet. description: Optional; wird spaeter fuer Optimistic Locking ausgewertet.
DocumentHistoryEntry: DocumentHistoryEntry:
description: >
Ein Stand der nutzersichtbaren Historie. Sync-Versionen des
Live-Editings erscheinen hier nicht - sie tragen das Protokoll, nicht
die Erzaehlung.
type: object type: object
required: [documentId, version, title, content, changeType, timestamp] required: [documentId, version, title, content, changeType, timestamp]
properties: properties:
@@ -268,7 +274,11 @@ components:
type: string type: string
changeType: changeType:
type: string 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: timestamp:
type: string type: string
format: date-time format: date-time
@@ -159,6 +159,16 @@ class DocumentStepDefinitions {
.returnResult(String::class.java) .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") @Und("die Antwort enthält {int} Historieneinträge")
fun `die Antwort enthaelt n Historieneintraege`(anzahl: Int) { fun `die Antwort enthaelt n Historieneintraege`(anzahl: Int) {
val count = Regex("\"changeType\"").findAll(body()).count() 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.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.mockk.every import io.mockk.every
import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.runs import io.mockk.CapturingSlot
import io.mockk.slot import io.mockk.slot
import io.mockk.verify import io.mockk.verify
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.time.Clock import java.time.Clock
import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
class DocumentServiceTest { class DocumentServiceTest {
private val fixedClock: Clock = /** Uhr, die sich im Test weiterstellen lässt für die Schreibpause-Regel. */
Clock.fixed(Instant.parse("2026-01-01T12:00:00Z"), ZoneOffset.UTC) 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 repository = mockk<DocumentRepository>()
private val historyRepository = mockk<DocumentHistoryRepository>(relaxUnitFun = true) private val historyRepository = mockk<DocumentHistoryRepository>(relaxed = true)
private val service = DocumentService(repository, historyRepository, fixedClock) private val service = DocumentService(repository, historyRepository, clock, properties)
private fun sampleDocument( private fun sampleDocument(
id: UUID = UUID.randomUUID(), id: UUID = UUID.randomUUID(),
@@ -37,8 +48,8 @@ class DocumentServiceTest {
title = "Titel", title = "Titel",
content = "Inhalt", content = "Inhalt",
version = version, version = version,
createdAt = OffsetDateTime.now(fixedClock), createdAt = OffsetDateTime.now(clock),
updatedAt = OffsetDateTime.now(fixedClock), updatedAt = OffsetDateTime.now(clock),
) )
private fun historyEntry( private fun historyEntry(
@@ -47,28 +58,40 @@ class DocumentServiceTest {
changeType: ChangeType, changeType: ChangeType,
title: String = "Titel v$version", title: String = "Titel v$version",
content: String = "Inhalt v$version", content: String = "Inhalt v$version",
milestone: Boolean = true,
timestamp: OffsetDateTime = OffsetDateTime.now(clock),
) = DocumentHistoryEntry( ) = DocumentHistoryEntry(
documentId = id, documentId = id,
version = version, version = version,
title = title, title = title,
content = content, content = content,
changeType = changeType, 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 @Test
fun `create legt Dokument an und schreibt CREATED-Historieneintrag`() { fun `create legt Dokument an und schreibt CREATED-Historieneintrag`() {
val saved = slot<Document>() val saved = slot<Document>()
every { repository.save(capture(saved)) } answers { saved.captured } every { repository.save(capture(saved)) } answers { saved.captured }
val historyEntry = slot<DocumentHistoryEntry>() val entry = captureAppended()
every { historyRepository.append(capture(historyEntry)) } just runs
val result = service.create(title = "Notizen", content = "Hallo") val result = service.create(title = "Notizen", content = "Hallo")
result.version shouldBe 1 result.version shouldBe 1
historyEntry.captured.changeType shouldBe ChangeType.CREATED entry.captured.changeType shouldBe ChangeType.CREATED
historyEntry.captured.documentId shouldBe result.id entry.captured.documentId shouldBe result.id
historyEntry.captured.content shouldBe "Hallo" entry.captured.content shouldBe "Hallo"
} }
@Test @Test
@@ -77,14 +100,13 @@ class DocumentServiceTest {
every { repository.findById(doc.id) } returns doc every { repository.findById(doc.id) } returns doc
val saved = slot<Document>() val saved = slot<Document>()
every { repository.save(capture(saved)) } answers { saved.captured } every { repository.save(capture(saved)) } answers { saved.captured }
val historyEntry = slot<DocumentHistoryEntry>() val entry = captureAppended()
every { historyRepository.append(capture(historyEntry)) } just runs
val result = service.update(doc.id, title = "Neu", content = "Neuer Inhalt") val result = service.update(doc.id, title = "Neu", content = "Neuer Inhalt")
result.version shouldBe 4 result.version shouldBe 4
historyEntry.captured.changeType shouldBe ChangeType.UPDATED entry.captured.changeType shouldBe ChangeType.UPDATED
historyEntry.captured.version shouldBe 4 entry.captured.version shouldBe 4
} }
@Test @Test
@@ -92,14 +114,13 @@ class DocumentServiceTest {
val doc = sampleDocument(version = 2) val doc = sampleDocument(version = 2)
every { repository.findById(doc.id) } returns doc every { repository.findById(doc.id) } returns doc
every { repository.deleteById(doc.id) } returns true every { repository.deleteById(doc.id) } returns true
val historyEntry = slot<DocumentHistoryEntry>() val entry = captureAppended()
every { historyRepository.append(capture(historyEntry)) } just runs
service.delete(doc.id) service.delete(doc.id)
verify(exactly = 1) { repository.deleteById(doc.id) } verify(exactly = 1) { repository.deleteById(doc.id) }
historyEntry.captured.changeType shouldBe ChangeType.DELETED entry.captured.changeType shouldBe ChangeType.DELETED
historyEntry.captured.version shouldBe 3 entry.captured.version shouldBe 3
} }
@Test @Test
@@ -110,39 +131,156 @@ class DocumentServiceTest {
shouldThrow<DocumentNotFoundException> { service.delete(id) } shouldThrow<DocumentNotFoundException> { service.delete(id) }
} }
// -----------------------------------------------------------------------
// Zwei Ebenen: Meilensteine und Sync-Versionen
// -----------------------------------------------------------------------
@Test @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 id = UUID.randomUUID()
val entries = listOf( val meilensteine = listOf(
historyEntry(id, 1, ChangeType.CREATED), historyEntry(id, 1, ChangeType.CREATED),
historyEntry(id, 2, ChangeType.DELETED), 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 @Test
fun `history wirft Exception bei gaenzlich unbekannter ID`() { fun `history wirft Exception bei gaenzlich unbekannter ID`() {
val id = UUID.randomUUID() val id = UUID.randomUUID()
every { historyRepository.findByDocumentId(id) } returns emptyList() every { historyRepository.exists(id) } returns false
shouldThrow<DocumentNotFoundException> { service.history(id) } shouldThrow<DocumentNotFoundException> { service.history(id) }
} }
// -----------------------------------------------------------------------
// Wiederherstellen und Rückfall
// -----------------------------------------------------------------------
@Test @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() val id = UUID.randomUUID()
every { historyRepository.findByDocumentId(id) } returns listOf( every { historyRepository.exists(id) } returns true
historyEntry(id, 1, ChangeType.CREATED), every { historyRepository.findLatest(id) } returns
historyEntry(id, 2, ChangeType.UPDATED), historyEntry(id, 3, ChangeType.DELETED, title = "Titel v2", content = "Inhalt v2")
historyEntry(id, 3, ChangeType.DELETED), every { historyRepository.maxVersion(id) } returns 3
) every { historyRepository.findOldest(id) } returns historyEntry(id, 1, ChangeType.CREATED)
every { repository.findById(id) } returns null every { repository.findById(id) } returns null
val saved = slot<Document>() val saved = slot<Document>()
every { repository.save(capture(saved)) } answers { saved.captured } every { repository.save(capture(saved)) } answers { saved.captured }
val historyEntry = slot<DocumentHistoryEntry>() val entry = captureAppended()
every { historyRepository.append(capture(historyEntry)) } just runs
val result = service.restore(id) val result = service.restore(id)
@@ -150,35 +288,43 @@ class DocumentServiceTest {
result.title shouldBe "Titel v2" result.title shouldBe "Titel v2"
result.content shouldBe "Inhalt v2" result.content shouldBe "Inhalt v2"
result.version shouldBe 4 result.version shouldBe 4
historyEntry.captured.changeType shouldBe ChangeType.RESTORED entry.captured.changeType shouldBe ChangeType.RESTORED
} }
@Test @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 id = UUID.randomUUID()
val existing = sampleDocument(id = id, version = 3) val existing = sampleDocument(id = id, version = 3)
every { historyRepository.findByDocumentId(id) } returns listOf( every { historyRepository.exists(id) } returns true
historyEntry(id, 1, ChangeType.CREATED), every { historyRepository.findVersion(id, 1) } returns
historyEntry(id, 2, ChangeType.UPDATED), historyEntry(id, 1, ChangeType.CREATED)
historyEntry(id, 3, ChangeType.UPDATED), every { historyRepository.maxVersion(id) } returns 3
)
every { repository.findById(id) } returns existing every { repository.findById(id) } returns existing
val saved = slot<Document>() every { repository.save(any()) } answers { firstArg() }
every { repository.save(capture(saved)) } answers { saved.captured } val entry = captureAppended()
every { historyRepository.append(any()) } just runs
val result = service.restore(id, targetVersion = 1) val result = service.restore(id, targetVersion = 1)
result.title shouldBe "Titel v1" result.title shouldBe "Titel v1"
result.version shouldBe 4 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 @Test
fun `restore ohne Zielversion wirft Konflikt wenn Dokument noch existiert`() { fun `restore ohne Zielversion wirft Konflikt wenn Dokument noch existiert`() {
val id = UUID.randomUUID() val id = UUID.randomUUID()
every { historyRepository.findByDocumentId(id) } returns listOf( every { historyRepository.exists(id) } returns true
historyEntry(id, 1, ChangeType.CREATED),
)
every { repository.findById(id) } returns sampleDocument(id = id) every { repository.findById(id) } returns sampleDocument(id = id)
shouldThrow<DocumentConflictException> { service.restore(id) } shouldThrow<DocumentConflictException> { service.restore(id) }
@@ -187,11 +333,13 @@ class DocumentServiceTest {
@Test @Test
fun `restore wirft Exception bei unbekannter ID`() { fun `restore wirft Exception bei unbekannter ID`() {
val id = UUID.randomUUID() val id = UUID.randomUUID()
every { historyRepository.findByDocumentId(id) } returns emptyList() every { historyRepository.exists(id) } returns false
shouldThrow<DocumentNotFoundException> { service.restore(id) } shouldThrow<DocumentNotFoundException> { service.restore(id) }
} }
// -----------------------------------------------------------------------
@Test @Test
fun `findById wirft Exception bei unbekannter ID`() { fun `findById wirft Exception bei unbekannter ID`() {
val id = UUID.randomUUID() val id = UUID.randomUUID()
@@ -64,6 +64,15 @@ Funktionalität: Dokumente verwalten
Und die Antwort enthält den Titel "Vertrag" Und die Antwort enthält den Titel "Vertrag"
Und das Dokument ist wieder abrufbar 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 Szenario: Wiederherstellen ohne Historie schlägt fehl
Wenn ich ein Dokument mit einer unbekannten ID abrufe Wenn ich ein Dokument mit einer unbekannten ID abrufe
Dann erhalte ich den Status 404 Dann erhalte ich den Status 404