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>
6.7 KiB
6.7 KiB
Editor Backend – Grundgerippe
CRUD-Skelett mit Spring Boot 4, Kotlin und API First (OpenAPI 3, YAML).
Die vier HTTP-Befehle (GET, POST, PUT, DELETE) sind für die Ressource Document
umgesetzt – bewusst noch ohne Autorisierung, aber mit vorbereiteten
Erweiterungspunkten für Live-Editing, Autorisierung und clientseitige
Verschlüsselung.
Voraussetzungen
- JDK 21
- Gradle 9 (einmalig
gradle wrapper --gradle-version 9.1ausführen, danach./gradlew)
Wichtige Kommandos
| Kommando | Zweck |
|---|---|
./gradlew openApiGenerate |
Generiert API-Interfaces + Modelle aus src/main/resources/openapi/api.yaml |
./gradlew build |
Generierung, Kompilierung, alle Tests, Coverage-Prüfung |
./gradlew test |
Unit- und Behavior-Tests (Cucumber) |
./gradlew jacocoTestReport |
Coverage-Report unter build/reports/jacoco/test/html |
./gradlew bootRun |
Startet das Backend auf Port 8080 |
API First – Ablauf
- Vertrag ändern:
src/main/resources/openapi/api.yaml ./gradlew openApiGenerate→ erzeugtDocumentsApi(Interface) und Modelle nachbuild/generated/openapi(Paketede.werkbaum.generated.*)DocumentsControllerimplementiert das Interface mitskipDefaultInterface=true: Weicht die Implementierung vom Vertrag ab, bricht der Build – Spezifikation und Code können nicht auseinanderlaufen.
Generierter Code wird nicht eingecheckt und zählt nicht zur Code Coverage.
Teststrategie
- Behavior-Tests (Cucumber,
src/test/resources/features/dokumente.feature) testen die API von außen gegen die laufende Anwendung (RANDOM_PORT): Statuscodes, Payloads, Fehlerpfade. Die Szenarien sind auf Deutsch (# language: de) und dienen als lebende Dokumentation. - Unit-Tests (JUnit 5 + MockK) decken die Geschäftslogik im
DocumentServiceisoliert ab (Versionierung, Zeitstempel per festemClock, Fehlerfälle). - Coverage: JaCoCo, Verifikation mit mind. 80 % Line Coverage
(
jacocoTestCoverageVerification, hängt ancheck).
Architektur
api/ DocumentsController (implementiert generiertes Interface),
GlobalExceptionHandler (RFC 9457 ProblemDetail)
service/ DocumentService (Geschäftslogik, Versionierung), Clock-Bean
repository/ DocumentRepository + DocumentHistoryRepository (Interfaces)
persistence/ JPA-Entities, Spring-Data-Repositories und Adapter (H2/Liquibase)
domain/ Document (internes Modell, getrennt vom API-Modell)
Das interne Domänenmodell ist bewusst vom generierten API-Modell getrennt – so können API-Vertrag und Persistenz unabhängig voneinander weiterentwickelt werden.
Historie & Wiederherstellung
- 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
PUTund 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), sobaldPATCH /contentihn durchreicht. - Stellschrauben:
werkbaum.live-editing.milestone-pause(30 s) undsync-retention(1 h).
- Meilenstein wird ein Stand bei einer strukturellen Änderung (Anlegen,
Löschen, Wiederherstellen, Rückfall), beim Vollersatz per
GET /api/v1/documents/{uuid}/historyliefert 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}/restorestellt ein gelöschtes Dokument unter 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
Autorisierung
bearerAuth(JWT) ist in der OpenAPI-Spec als Security Scheme definiert, aber noch auf keine Operation angewendet.- Später:
spring-boot-starter-security+security: [bearerAuth]in der Spec; die Behavior-Tests erhalten dann einen Auth-Schritt („Angenommen ich bin als … angemeldet").
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 /contentsamt Rebase und Idempotenz, der Änderungsfeed per Long Polling, Master-Passwort fürGET /documents, Client-Anpassung. DocumentUpdateRequest.expectedVersionist im Vertrag vorgesehen, wird aber noch nicht ausgewertet.
Clientseitige Verschlüsselung
contentist ein opaker String, den der Server nie interpretiert. Der Wechsel auf Ciphertext erfordert keine API-Änderung; ggf. kommen später Metadaten-Felder (z. B. Schlüssel-ID, Nonce) als eigene Properties hinzu.
Persistenz
- H2 im File-Modus (
./data/editor.mv.db) mitMODE=PostgreSQL– läuft im Server-Prozess, keine Datenbank-Installation nötig. Dokumente und Historie überleben einen Neustart. - Schema per Liquibase im formatierten SQL-Format
(
src/main/resources/db/changelog/db.changelog-master.sql, kein XML). Neue Änderungen werden als weitere--changeset-Blöcke angehängt; Hibernate validiert nur (ddl-auto: validate). - Umstieg auf echtes PostgreSQL: im Wesentlichen JDBC-URL/Credentials in
der
application.yamltauschen und den Postgres-Treiber als Dependency ergänzen – Schema-Migrationen und Code bleiben unverändert. - Tests laufen gegen H2 in-memory (
src/test/resources/application.yaml), mit demselben Liquibase-Schema. - Service-Methoden sind
@Transactional: Dokument-Änderung und Historieneintrag werden atomar geschrieben.
Hinweise
- Versionsnummern in
build.gradle.kts(Spring Boot, OpenAPI Generator, Cucumber, MockK) beim ersten Build ggf. auf den aktuellen Patch-Stand heben.