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:
co-authored by
Claude Opus 5
parent
0f3bd8f5b8
commit
d5cdff6058
+4
-2
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user