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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-26 16:51:46 +02:00
co-authored by Claude Opus 5
parent 0f3bd8f5b8
commit d5cdff6058
16 changed files with 530 additions and 94 deletions
@@ -1,9 +1,11 @@
package de.werkbaum
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
@SpringBootApplication
@ConfigurationPropertiesScan
class EditorBackendApplication
fun main(args: Array<String>) {
@@ -3,12 +3,33 @@ package de.werkbaum.domain
import java.time.OffsetDateTime
import java.util.UUID
enum class ChangeType { CREATED, UPDATED, DELETED, RESTORED }
/**
* Art der Änderung.
*
* [RESTORED] heißt ausschließlich: ein **gelöschtes** Dokument ist wieder da —
* der Client hebt daraufhin seine Sperre auf. [ROLLED_BACK] ist der Rückfall
* eines **lebenden** Dokuments auf eine alte Version; für den Client ein
* gewöhnlicher Inhaltswechsel. Beide trugen früher denselben Typ; ein Typ, der
* zwei Dinge bedeutet, ist die Unschärfe, aus der später Fehler werden (D76).
*/
enum class ChangeType { CREATED, UPDATED, DELETED, RESTORED, ROLLED_BACK }
/**
* Ein Eintrag der Dokumenthistorie. Die Historie wird getrennt vom Dokument
* gespeichert und überlebt daher ein DELETE Grundlage für die
* Wiederherstellung und später auch für Audit/Live-Editing-Replays.
* Wiederherstellung und für die Diffs des Live-Editings.
*
* Die Historie hat **zwei Ebenen** (D76):
* - [milestone] `false` — eine **Sync-Version**. Sie trägt das Protokoll
* (Diffs zwischen beliebigen Versionen), ist kurzlebig und wird nach einer
* Weile verdichtet. Danach beantwortet der Feed betroffene `since`-Werte
* mit Volltext.
* - [milestone] `true` — ein **Meilenstein**, die nutzersichtbare Historie.
* Meilensteine entstehen bei strukturellen Änderungen, nach einer
* Schreibpause und auf Knopfdruck; sie werden nie verdichtet.
*
* Ohne die Trennung würde die Historie bei 1,5 s Debounce zum Transaktionslog:
* hunderte Volltext-Snapshots eines 40-kB-Dokuments je Sitzung.
*/
data class DocumentHistoryEntry(
val documentId: UUID,
@@ -17,4 +38,5 @@ data class DocumentHistoryEntry(
val content: String,
val changeType: ChangeType,
val timestamp: OffsetDateTime,
val milestone: Boolean = true,
)
@@ -38,6 +38,10 @@ class DocumentHistoryEntity(
@Column(name = "change_time", nullable = false)
val changeTime: OffsetDateTime,
/** Meilenstein (nutzersichtbar, bleibt) oder Sync-Version (wird verdichtet) D76. */
@Column(nullable = false)
var milestone: Boolean = true,
) {
fun toDomain() = DocumentHistoryEntry(
documentId = documentId,
@@ -46,6 +50,7 @@ class DocumentHistoryEntity(
content = content,
changeType = changeType,
timestamp = changeTime,
milestone = milestone,
)
companion object {
@@ -56,6 +61,7 @@ class DocumentHistoryEntity(
content = entry.content,
changeType = entry.changeType,
changeTime = entry.timestamp,
milestone = entry.milestone,
)
}
}
@@ -3,6 +3,7 @@ package de.werkbaum.persistence
import de.werkbaum.domain.DocumentHistoryEntry
import de.werkbaum.repository.DocumentHistoryRepository
import org.springframework.stereotype.Repository
import java.time.OffsetDateTime
import java.util.UUID
@Repository
@@ -14,8 +15,28 @@ class JpaDocumentHistoryRepository(
jpa.save(DocumentHistoryEntity.fromDomain(entry))
}
override fun findByDocumentId(documentId: UUID): List<DocumentHistoryEntry> =
jpa.findByDocumentIdOrderByIdAsc(documentId).map { it.toDomain() }
override fun exists(documentId: UUID): Boolean = jpa.existsByDocumentId(documentId)
override fun findVersion(documentId: UUID, version: Long): DocumentHistoryEntry? =
jpa.findFirstByDocumentIdAndVersionOrderByIdDesc(documentId, version)?.toDomain()
override fun findLatest(documentId: UUID): DocumentHistoryEntry? =
jpa.findFirstByDocumentIdOrderByIdDesc(documentId)?.toDomain()
override fun findOldest(documentId: UUID): DocumentHistoryEntry? =
jpa.findFirstByDocumentIdOrderByIdAsc(documentId)?.toDomain()
override fun maxVersion(documentId: UUID): Long? = jpa.maxVersion(documentId)
override fun findMilestones(documentId: UUID): List<DocumentHistoryEntry> =
jpa.findByDocumentIdAndMilestoneTrueOrderByIdAsc(documentId).map { it.toDomain() }
override fun promoteToMilestone(documentId: UUID, version: Long) {
jpa.promoteToMilestone(documentId, version)
}
override fun compact(documentId: UUID, olderThan: OffsetDateTime): Int =
jpa.deleteSyncVersionsOlderThan(documentId, olderThan)
override fun clear() = jpa.deleteAll()
}
@@ -1,10 +1,53 @@
package de.werkbaum.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.time.OffsetDateTime
import java.util.UUID
interface DocumentJpaRepository : JpaRepository<DocumentEntity, UUID>
interface DocumentHistoryJpaRepository : JpaRepository<DocumentHistoryEntity, Long> {
fun findByDocumentIdOrderByIdAsc(documentId: UUID): List<DocumentHistoryEntity>
fun existsByDocumentId(documentId: UUID): Boolean
fun findFirstByDocumentIdAndVersionOrderByIdDesc(
documentId: UUID,
version: Long,
): DocumentHistoryEntity?
fun findFirstByDocumentIdOrderByIdDesc(documentId: UUID): DocumentHistoryEntity?
fun findFirstByDocumentIdOrderByIdAsc(documentId: UUID): DocumentHistoryEntity?
fun findByDocumentIdAndMilestoneTrueOrderByIdAsc(documentId: UUID): List<DocumentHistoryEntity>
@Query("select max(e.version) from DocumentHistoryEntity e where e.documentId = :documentId")
fun maxVersion(@Param("documentId") documentId: UUID): Long?
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(
"""
update DocumentHistoryEntity e set e.milestone = true
where e.documentId = :documentId and e.version = :version
"""
)
fun promoteToMilestone(
@Param("documentId") documentId: UUID,
@Param("version") version: Long,
): Int
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(
"""
delete from DocumentHistoryEntity e
where e.documentId = :documentId and e.milestone = false and e.changeTime < :cutoff
"""
)
fun deleteSyncVersionsOlderThan(
@Param("documentId") documentId: UUID,
@Param("cutoff") cutoff: OffsetDateTime,
): Int
}
@@ -1,13 +1,49 @@
package de.werkbaum.repository
import de.werkbaum.domain.DocumentHistoryEntry
import java.time.OffsetDateTime
import java.util.UUID
/**
* Zugriff auf die Dokumenthistorie.
*
* Bewusst **gezielt** statt „lade alles und filtere in Kotlin": Mit dem
* Live-Editing entstehen hunderte Versionen je Dokument, und jede trägt den
* vollen Text (D76).
*/
interface DocumentHistoryRepository {
fun append(entry: DocumentHistoryEntry)
/** Alle Einträge zu einem Dokument, älteste zuerst. */
fun findByDocumentId(documentId: UUID): List<DocumentHistoryEntry>
/** Gibt es zu dieser UUID überhaupt Historie? Auch für gelöschte Dokumente wahr. */
fun exists(documentId: UUID): Boolean
/** Genau eine Version oder `null`, wenn sie nie existierte oder verdichtet wurde. */
fun findVersion(documentId: UUID, version: Long): DocumentHistoryEntry?
/** Der jüngste Eintrag, gleich welchen Typs (bei gelöschten Dokumenten der Tombstone). */
fun findLatest(documentId: UUID): DocumentHistoryEntry?
/** Der älteste Eintrag die Anlage des Dokuments, immer ein Meilenstein. */
fun findOldest(documentId: UUID): DocumentHistoryEntry?
/** Höchste vergebene Versionsnummer, auch wenn deren Eintrag verdichtet wurde. */
fun maxVersion(documentId: UUID): Long?
/** Die nutzersichtbare Historie, älteste zuerst. */
fun findMilestones(documentId: UUID): List<DocumentHistoryEntry>
/**
* Erhebt eine Sync-Version nachträglich zum Meilenstein sie war die
* letzte vor einer Schreibpause.
*/
fun promoteToMilestone(documentId: UUID, version: Long)
/**
* Verdichtet: entfernt Sync-Versionen dieses Dokuments, die älter als
* [olderThan] sind. Meilensteine bleiben. Liefert die Zahl der entfernten
* Einträge.
*/
fun compact(documentId: UUID, olderThan: OffsetDateTime): Int
fun clear()
}
@@ -8,6 +8,7 @@ import de.werkbaum.repository.DocumentRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Clock
import java.time.Duration
import java.time.OffsetDateTime
import java.util.UUID
@@ -17,6 +18,7 @@ class DocumentService(
private val repository: DocumentRepository,
private val historyRepository: DocumentHistoryRepository,
private val clock: Clock,
private val properties: LiveEditingProperties,
) {
fun findAll(): List<Document> = repository.findAll()
@@ -39,7 +41,16 @@ class DocumentService(
return document
}
fun update(id: UUID, title: String, content: String): Document {
/**
* Ersetzt Titel und Inhalt vollständig.
*
* [milestone] `false` schreibt eine **Sync-Version** gedacht für den
* getakteten Strom des Live-Editings (D76), der sonst hunderte
* nutzersichtbare Stände je Sitzung erzeugte. Der Vollersatz über die API
* ist dagegen eine bewusste Handlung (Import, Reparatur) und bleibt
* Meilenstein.
*/
fun update(id: UUID, title: String, content: String, milestone: Boolean = true): Document {
val existing = findById(id)
val updated = existing.copy(
title = title,
@@ -48,7 +59,7 @@ class DocumentService(
updatedAt = OffsetDateTime.now(clock),
)
repository.save(updated)
recordHistory(updated, ChangeType.UPDATED)
recordHistory(updated, ChangeType.UPDATED, milestone)
return updated
}
@@ -66,26 +77,42 @@ class DocumentService(
}
/**
* Historie eines Dokuments funktioniert auch für bereits gelöschte
* Dokumente. 404 nur, wenn die UUID gänzlich unbekannt ist.
* Die **nutzersichtbare** Historie: alle Meilensteine, älteste zuerst,
* dazu immer der jüngste Stand. Sync-Versionen bleiben draußen sie
* tragen das Protokoll, nicht die Erzählung (D76).
*
* Funktioniert auch für bereits gelöschte Dokumente; 404 nur, wenn die
* UUID gänzlich unbekannt ist.
*/
fun history(id: UUID): List<DocumentHistoryEntry> {
val entries = historyRepository.findByDocumentId(id)
if (entries.isEmpty()) throw DocumentNotFoundException(id)
return entries
if (!historyRepository.exists(id)) throw DocumentNotFoundException(id)
val milestones = historyRepository.findMilestones(id)
// Die letzte Version einer noch laufenden Schreibphase ist noch kein
// Meilenstein sichtbar sein muss sie trotzdem.
val latest = historyRepository.findLatest(id)
return if (latest != null && milestones.none { it.version == latest.version }) {
milestones + latest
} else {
milestones
}
}
/**
* Stellt ein Dokument unter derselben UUID wieder her.
*
* - Ohne [targetVersion]: letzter inhaltlicher Stand vor dem Löschen.
* - Ohne [targetVersion]: letzter Stand vor dem Löschen ([ChangeType.RESTORED]).
* Existiert das Dokument noch, gibt es einen Konflikt (409).
* - Mit [targetVersion]: Inhalt dieser Version wird als neue Version
* übernommen funktioniert auch als Rollback für existierende Dokumente.
* übernommen. Bei einem lebenden Dokument ist das ein Rückfall
* ([ChangeType.ROLLED_BACK]), kein Wiederherstellen der Client hatte
* nie eine Sperre.
*
* Eine verdichtete Sync-Version ist nicht mehr anzusteuern (404). Das ist
* die Zwei-Ebenen-Regel im Betrieb: Angeboten werden Meilensteine, und die
* bleiben.
*/
fun restore(id: UUID, targetVersion: Long? = null): Document {
val entries = historyRepository.findByDocumentId(id)
if (entries.isEmpty()) throw DocumentNotFoundException(id)
if (!historyRepository.exists(id)) throw DocumentNotFoundException(id)
val existing = repository.findById(id)
if (existing != null && targetVersion == null) {
@@ -95,28 +122,59 @@ class DocumentService(
}
val snapshot = if (targetVersion != null) {
entries.lastOrNull { it.version == targetVersion && it.changeType != ChangeType.DELETED }
historyRepository.findVersion(id, targetVersion)
?.takeIf { it.changeType != ChangeType.DELETED }
?: throw DocumentNotFoundException(id)
} else {
entries.last { it.changeType != ChangeType.DELETED }
// Der Tombstone trägt den letzten Stand er ist die verlässliche
// Quelle, auch wenn die Version davor längst verdichtet wurde.
historyRepository.findLatest(id) ?: throw DocumentNotFoundException(id)
}
val now = OffsetDateTime.now(clock)
val lastVersion = maxOf(entries.maxOf { it.version }, existing?.version ?: 0)
val lastVersion = maxOf(historyRepository.maxVersion(id) ?: 0, existing?.version ?: 0)
val restored = Document(
id = id,
title = snapshot.title,
content = snapshot.content,
version = lastVersion + 1,
createdAt = existing?.createdAt ?: entries.first().timestamp,
createdAt = existing?.createdAt
?: historyRepository.findOldest(id)?.timestamp
?: now,
updatedAt = now,
)
repository.save(restored)
recordHistory(restored, ChangeType.RESTORED)
recordHistory(
restored,
if (existing != null) ChangeType.ROLLED_BACK else ChangeType.RESTORED,
)
return restored
}
private fun recordHistory(document: Document, changeType: ChangeType) {
/**
* Schreibt einen Historieneintrag und hält dabei die zwei Ebenen instand:
*
* 1. War die vorige Version eine Sync-Version und liegt sie länger als
* [LiveEditingProperties.milestonePause] zurück, war sie die **letzte
* vor einer Schreibpause** und wird nachträglich Meilenstein. So
* braucht es keinen Zeitgeber die nächste Änderung stellt fest, dass
* eine Pause war.
* 2. Strukturelle Änderungen sind immer Meilensteine.
* 3. Danach wird verdichtet: Sync-Versionen jenseits der
* Aufbewahrungsfrist entfallen.
*/
private fun recordHistory(
document: Document,
changeType: ChangeType,
milestone: Boolean = true,
) {
val previous = historyRepository.findLatest(document.id)
if (previous != null && !previous.milestone &&
Duration.between(previous.timestamp, document.updatedAt) >= properties.milestonePause
) {
historyRepository.promoteToMilestone(document.id, previous.version)
}
historyRepository.append(
DocumentHistoryEntry(
documentId = document.id,
@@ -125,7 +183,17 @@ class DocumentService(
content = document.content,
changeType = changeType,
timestamp = document.updatedAt,
milestone = milestone || changeType.isStructural,
)
)
historyRepository.compact(
document.id,
document.updatedAt.minus(properties.syncRetention),
)
}
/** Anlegen, Löschen, Wiederherstellen und Rückfall sind nie bloß Sync-Versionen. */
private val ChangeType.isStructural: Boolean
get() = this != ChangeType.UPDATED
}
@@ -0,0 +1,25 @@
package de.werkbaum.service
import org.springframework.boot.context.properties.ConfigurationProperties
import java.time.Duration
/**
* Stellschrauben des Live-Editings (D76). Bewusst konfigurierbar: Die Werte
* sind gesetzt, nicht hergeleitet, und werden nach Erfahrung justiert wie
* die Schwellen im Frontend (D64, D71).
*/
@ConfigurationProperties(prefix = "werkbaum.live-editing")
data class LiveEditingProperties(
/**
* Schreibpause, nach der die letzte Version zum Meilenstein wird. Kürzer
* heißt mehr nutzersichtbare Stände, länger heißt gröbere Historie.
*/
val milestonePause: Duration = Duration.ofSeconds(30),
/**
* Wie lange Sync-Versionen aufgehoben werden. Danach beantwortet der Feed
* ein so altes `since` mit Volltext statt mit einem Diff.
*/
val syncRetention: Duration = Duration.ofHours(1),
)
@@ -18,5 +18,13 @@ spring:
liquibase:
change-log: classpath:db/changelog/db.changelog-master.sql
werkbaum:
live-editing:
# Schreibpause, nach der die letzte Version zum Meilenstein wird.
milestone-pause: 30s
# Danach wird eine Sync-Version verdichtet; der Feed antwortet auf ein so
# altes "since" dann mit Volltext statt mit einem Diff.
sync-retention: 1h
server:
port: 8080
@@ -26,3 +26,13 @@ CREATE TABLE document_history (
--changeset editor:003-index-document-history
CREATE INDEX idx_document_history_document_id ON document_history (document_id);
--rollback DROP INDEX idx_document_history_document_id;
--changeset editor:004-history-milestone
-- Zwei Ebenen (D76): Meilensteine sind die nutzersichtbare Historie und
-- bleiben; Sync-Versionen tragen die Diffs des Live-Editings und werden nach
-- einer Weile verdichtet. Bestand ist Meilenstein - er stammt aus der Zeit
-- ohne Live-Editing und ist durchweg nutzersichtbar.
ALTER TABLE document_history ADD COLUMN milestone BOOLEAN DEFAULT TRUE NOT NULL;
CREATE INDEX idx_document_history_version ON document_history (document_id, version);
--rollback DROP INDEX idx_document_history_version;
--rollback ALTER TABLE document_history DROP COLUMN milestone;
+14 -4
View File
@@ -127,11 +127,13 @@ paths:
operationId: getDocumentHistory
summary: Historie eines Dokuments abrufen
description: >
Liefert alle Versionen eines Dokuments in chronologischer Reihenfolge.
Die Historie bleibt auch nach dem Loeschen des Dokuments erhalten.
Liefert die nutzersichtbaren Staende (Meilensteine) in chronologischer
Reihenfolge, dazu immer den juengsten Stand. Kurzlebige Sync-Versionen
des Live-Editings bleiben aussen vor. Die Historie ueberlebt das
Loeschen des Dokuments.
responses:
"200":
description: Historie des Dokuments (aelteste zuerst)
description: Meilensteine des Dokuments (aelteste zuerst)
content:
application/json:
schema:
@@ -253,6 +255,10 @@ components:
description: Optional; wird spaeter fuer Optimistic Locking ausgewertet.
DocumentHistoryEntry:
description: >
Ein Stand der nutzersichtbaren Historie. Sync-Versionen des
Live-Editings erscheinen hier nicht - sie tragen das Protokoll, nicht
die Erzaehlung.
type: object
required: [documentId, version, title, content, changeType, timestamp]
properties:
@@ -268,7 +274,11 @@ components:
type: string
changeType:
type: string
enum: [CREATED, UPDATED, DELETED, RESTORED]
description: >
RESTORED heisst: ein geloeschtes Dokument ist wieder da.
ROLLED_BACK ist der Rueckfall eines lebenden Dokuments auf eine
aeltere Version.
enum: [CREATED, UPDATED, DELETED, RESTORED, ROLLED_BACK]
timestamp:
type: string
format: date-time