Der Server rebased selbst: Ist die Basis veraltet, ueberschneiden sich die Operationen aber nicht mit den zwischenzeitlichen, verschiebt er sie und akzeptiert. Reines Ablehnen fuehrte zu Starvation — ein Client mit hoher Latenz kaeme bei fleissigen Mitschreibern womoeglich nie durch. 409 gibt es nur bei echter Ueberschneidung, mit allem, was der Client zum Weiterarbeiten braucht, ohne neu zu laden. Pruefsumme ist Pflicht (422 bei Abweichung): Die Versionsnummer bestaetigt nur, dass die Basis dieselbe Version ist, nicht dass beide Seiten sie gleich lesen. clientId + seq machen den Aufruf wiederholbar — im Mobilnetz ist die verlorene Antwort der Normalfall. Die Sperre je Dokument liegt ausserhalb der Transaktion: innen gaebe der Proxy sie vor dem Commit frei, und der naechste Schreiber laese einen Stand, der noch nicht steht. Deshalb ist LiveEditingService nicht transaktional und schreibt ueber DocumentService. Was das Konzept offenliess, ist jetzt entschieden und in D76-Nachtrag 4 begruendet: die Randfaelle der Einfuege-Ueberschneidung, die Trennung von 400 und 422, die gedeckelte Idempotenz im Speicher. 104 Tests, davon 8 Cucumber-Szenarien fuer das Live-Editing. Gegenprobe: Pruefsumme nicht geprueft, Idempotenz entfernt, veraltete Basis abgelehnt statt verschoben -> es fallen jeweils genau die danach benannten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Editor Backend – Grundgerippe
CRUD mit Spring Boot 4, Kotlin und API First (OpenAPI 3, YAML) für
die Ressource Document (GET, POST, PUT, DELETE), dazu Historie,
Wiederherstellung und das Einreichen von Zeilen-Diffs fürs Live-Editing
(PATCH …/content) – bewusst noch ohne Autorisierung, aber mit
vorbereiteten Erweiterungspunkten dafür und für 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/) 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 isoliert ab:
DocumentService(Versionierung, Zeitstempel per festemClock, Meilenstein-Regeln),LiveEditingService(Rebasen, Konflikt, Idempotenz, Grenzen) undLineDiff(Anwenden, Berechnen, Überschneidung, Prüfsumme). - Gegenprobe statt Zählerei: Zu jeder Regel wird geprüft, dass ihre Mutation genau die nach ihr benannten Zusicherungen fallen lässt. Ein Test, von dem man das nicht geprüft hat, ist nur eine Behauptung.
- Coverage: JaCoCo, Verifikation mit mind. 80 % Line Coverage
(
jacocoTestCoverageVerification, hängt ancheck).
Architektur
api/ DocumentsController (implementiert generiertes Interface),
GlobalExceptionHandler (RFC 9457 ProblemDetail), Diff-Mapper
service/ DocumentService (Geschäftslogik, Versionierung),
LiveEditingService (Diffs einreichen), PatchLog, Clock-Bean
diff/ Zeilen-Diff: anwenden, berechnen, rebasen, Prüfsumme (Spring-frei)
repository/ DocumentRepository + DocumentHistoryRepository (Interfaces)
persistence/ JPA-Entities, Spring-Data-Repositories und Adapter (H2/Liquibase)
domain/ Document, ContentPatch (interne Modelle, 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. Auf Knopfdruck setztPATCH /contentdenselben Schalter (milestone: true). - 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.
Live-Editing: Änderungen als Zeilen-Diff
PATCH /api/v1/documents/{uuid}/content nimmt eine Änderung als Diff gegen
eine Basisversion entgegen — Zeilen sind opake Strings, das Backend parst die
Notation nicht (D14).
{ "baseVersion": 41, "checksum": "sha256:9f2b…",
"clientId": "c-8a41…", "seq": 17,
"ops": [ { "op": "replace", "index": 12, "count": 1,
"lines": [" - [~] Backend (L) @ben"] } ] }
- Der Server rebased selbst. Ist die Basis veraltet, überschneiden sich die
Operationen aber nicht mit den zwischenzeitlichen, verschiebt er sie und
antwortet mit 200; die fremden Operationen stehen in
opsSinceBase, damit der Client seine Schattenkopie nachzieht. Reines Ablehnen führte zu Starvation — ein Client mit hoher Latenz käme bei fleißigen Mitschreibern womöglich nie durch. - 409 nur bei echter Überschneidung, mit
currentVersionund dem Diff von der eingereichten Basis dorthin — der Client entscheidet, ohne neu zu laden. Eine Einfügung ist dabei ein Punkt zwischen den Zeilen: an den Rändern eines fremden Blocks kein Konflikt, in seinem Inneren schon. checksumist Pflicht. Die Versionsnummer bestätigt nur, dass die Basis dieselbe Version ist, nicht dass beide Seiten sie gleich lesen. Passt sie nicht: 422, Client lädt einmal neu. Ebenso bei Index außerhalb, bereits verdichteter Basis und veralteterseq.clientId+seqmachen den Aufruf wiederholbar. Geht die Antwort unterwegs verloren, liefert eine Wiederholung das Ergebnis von damals, statt die Änderung ein zweites Mal anzuwenden.- 400 bei Grenzüberschreitung (
max-ops,max-content-length) und beidelete/replaceohnecount— als 0 gelesen täte die Operation stillschweigend nichts. - Ohne
milestone: trueentsteht eine Sync-Version (siehe Historie oben); der Knopfdruck setzt das Feld.
Die Änderung eines Dokuments läuft strikt sequenziell (Sperre je UUID, außerhalb der Transaktion — innen gäbe der Proxy sie vor dem Commit frei).
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)
- Offen: der Änderungsfeed per Long Polling (
GET /changes), 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.