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