Drei Teile, die das Frontend-Deploy nicht braucht: ein eigenes JDK 21 im Home des Servers (dort ist nur 17 installiert, und die Toolchain zu senken hiesse, Entwicklung und Produktion auseinanderlaufen zu lassen), ein systemd-User-Unit statt nohup, und die Proxy-Regel in der .htaccess — gemessen ist, dass das P-Flag auf diesem Hoster erlaubt ist und eine Verbindung 30 s durchhaelt. Der Port steht an genau einer Stelle: deploy-prod.sh setzt ihn in die Proxy-Regel, deploy-backend.sh in die Unit. Zwei Zahlen, die zueinander passen muessen, sind eine zu viel. Die JVM-Flags sind gemessen, nicht geschaetzt. Mein erster Entwurf setzte -Xmx384m; nachgemessen kam heraus, dass die Obergrenze der kleine Hebel ist: Ohne Freiraum-Verhaeltnisse behaelt der Kollektor den gewachsenen Heap, obwohl nach einem GC nur ~45 MB leben. Mit ihnen 174 MB RSS statt 291 MB ohne jede Angabe — auf einem Host mit rund 300 MB frei ist das der Unterschied zwischen "passt" und "draengt die Datenbank weiter in den Swap". Zwei Fallen sind eingebaut, weil beide nur am Ziel auffielen: systemd expandiert kein $HOME (deshalb %h), und `systemctl --user` findet ohne XDG_RUNTIME_DIR seinen Manager nicht. Geprueft bis an die SSH-Grenze: Das Jar startet mit genau den Flags der Unit in einer Sekunde, antwortet auf die Lebendprobe mit 404 und ist von aussen nicht erreichbar; die erzeugte Unit besteht systemd-analyze verify; der ganze Ablauf lief mit gestelltem ssh/rsync durch; deploy-prod.sh liefert die Proxy-Regel mit eingesetztem Port aus. Der Deploy selbst laeuft erst, wenn jemand ihn startet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
257 lines
13 KiB
Markdown
257 lines
13 KiB
Markdown
# 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.1` ausfü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
|
||
|
||
1. Vertrag ändern: `src/main/resources/openapi/api.yaml`
|
||
2. `./gradlew openApiGenerate` → erzeugt `DocumentsApi` (Interface) und Modelle
|
||
nach `build/generated/openapi` (Pakete `de.werkbaum.generated.*`)
|
||
3. `DocumentsController` implementiert das Interface mit
|
||
`skipDefaultInterface=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 festem `Clock`,
|
||
Meilenstein-Regeln), `LiveEditingService` (Rebasen, Konflikt, Idempotenz,
|
||
Grenzen) und `LineDiff` (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 an `check`).
|
||
|
||
## 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 `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. Auf Knopfdruck setzt `PATCH /content` denselben
|
||
Schalter (`milestone: true`).
|
||
- 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 (`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).
|
||
|
||
```json
|
||
{ "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 `currentVersion` und 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.
|
||
- **`checksum` ist 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 veralteter `seq`.
|
||
- **`clientId` + `seq` machen 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 bei
|
||
`delete`/`replace` ohne `count` — als 0 gelesen täte die Operation
|
||
stillschweigend nichts.
|
||
- Ohne `milestone: true` entsteht 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).
|
||
|
||
## Live-Editing: der Änderungsfeed
|
||
|
||
`GET /api/v1/documents/{uuid}/changes?since={version}&wait={sekunden}` liefert
|
||
alles, was seit `since` geschehen ist. Gibt es nichts, hält der Server die
|
||
Anfrage offen und antwortet **sofort**, sobald eine Änderung eintrifft; sonst
|
||
**204**, und der Client fragt erneut. Kosten im Leerlauf: eine offene Anfrage
|
||
je Beobachter, rund 2,4 Requests pro Minute.
|
||
|
||
- **Der Feed arbeitet auf der Historie, nicht am Dokument.** Ein gelöschtes
|
||
Dokument muss sein `DELETED` noch zustellen können — **404** gibt es deshalb
|
||
nur bei gänzlich unbekannter UUID.
|
||
- **Ist `since` verdichtet** (oder `0`, also Erstkontakt), kommt statt `ops`
|
||
der **Volltext**; `fromVersion` fehlt dann. Ein Roundtrip und ein
|
||
Sonderzustand weniger als ein eigener Fehlerpfad — und über hunderte
|
||
Versionen hinweg wäre der Cursor ohnehin nicht zu retten.
|
||
- **Ereignisse:** `CREATED`, `UPDATED`, `DELETED`, `RESTORED`, `ROLLED_BACK`,
|
||
je mit `clientId` und `displayName` des Absenders. (`RENAMED` kommt mit
|
||
`PATCH /title`; solange es das nicht gibt, wäre der Typ eine Zusage ohne
|
||
Deckung.)
|
||
- **`Cache-Control: no-store`** ist Pflicht: Ein Proxy dürfte sonst eine 204
|
||
zwischenspeichern, und der Feed stünde still.
|
||
- `wait` wird serverseitig geklemmt (`werkbaum.live-editing.max-wait`, 25 s).
|
||
|
||
Umgesetzt **blockierend auf virtuellen Threads** (`spring.threads.virtual`),
|
||
nicht mit `DeferredResult`: So behält der Endpunkt die aus der Spezifikation
|
||
generierte Signatur, und ein Wartender kostet trotzdem fast nichts.
|
||
Geweckt wird **nach dem Commit** — davor läse ein Beobachter einen Stand, der
|
||
noch nicht steht. Voraussetzung ist eine **Einzelinstanz**; hinter einem Load
|
||
Balancer erführe ein Beobachter auf der zweiten Instanz nichts. Begründung:
|
||
D76-Nachtrag 5.
|
||
|
||
## Master-Passwort für die Dokumentenliste
|
||
|
||
`GET /api/v1/documents` listet **alle** Dokumente und machte damit jede UUID
|
||
auffindbar — das Zugriffsmodell „unerratbarer Link" wäre hinfällig. Dieser eine
|
||
Endpunkt verlangt deshalb HTTP Basic mit dem Benutzer `werkbaum`:
|
||
|
||
```bash
|
||
export WERKBAUM_MASTER_PASSWORD_HASH="{bcrypt}$(htpasswd -bnBC 12 "" geheim | tr -d ':\n')"
|
||
```
|
||
|
||
- **Ohne gesetzten Hash ist die Liste gesperrt** (401), nicht offen. Ein
|
||
vergessener Konfigurationsschritt darf nichts preisgeben; beim Start warnt
|
||
das Log.
|
||
- Der Hash trägt sein Verfahren als Präfix (`{bcrypt}…`) — so steht in der
|
||
Konfiguration, womit gehasht wurde.
|
||
- **Nach `max-attempts` Fehlversuchen** (5) ist der Endpunkt für `lockout`
|
||
(15 min) gesperrt: **429** mit `Retry-After`. Die Sperre ist **global**, nicht
|
||
je Adresse — es gibt genau ein Passwort, und hinter einem Reverse Proxy sähe
|
||
der Server ohnehin für alle dieselbe Adresse. Der Preis: Wer falsch rät,
|
||
sperrt die Liste für alle; die Dokumente selbst bleiben über ihre UUID
|
||
erreichbar.
|
||
- Die API ist zustandslos: keine Sitzung, kein CSRF-Token (der schützte hier
|
||
nichts und bräche jeden Client).
|
||
|
||
## Vorbereitete Erweiterungen
|
||
|
||
**Autorisierung**
|
||
- Geschützt ist bisher **genau ein** Endpunkt, siehe unten. Alles andere ist
|
||
über die unerratbare UUID erreichbar — das ist das Zugriffsmodell, nicht
|
||
eine Lücke.
|
||
- `bearerAuth` (JWT) ist in der OpenAPI-Spec als Security Scheme definiert,
|
||
aber noch auf keine Operation angewendet. Später kommt echte
|
||
Authentifizierung als Schicht davor; am Protokoll ändert sich dadurch
|
||
nichts.
|
||
|
||
**Live-Editing** (Konzept: `docs/live-editing-proposal.md`, Entscheidung: D76)
|
||
- **Offen:** Umbenennen per `PATCH /title` (und damit das Ereignis
|
||
`RENAMED`) sowie die Client-Anpassung im Frontend.
|
||
- `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
|
||
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`) mit `MODE=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.yaml` tauschen 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.
|
||
|
||
## Betrieb auf der stabilen Instanz
|
||
|
||
`scripts/deploy-backend.sh` (im Repo-Wurzelordner) baut das Fat-Jar, legt es
|
||
samt systemd-User-Unit ins Home des Servers und startet den Dienst neu;
|
||
`scripts/install-jdk.sh` bringt einmalig ein JDK 21 dorthin. Der Dienst lauscht
|
||
nur auf `127.0.0.1` — von außen kommt man über die Proxy-Regel in
|
||
`scripts/prod.htaccess`, die der Frontend-Deploy mitspiegelt.
|
||
|
||
- **Speicher:** `-Xmx192m -Xms48m` plus Freiraum-Verhältnisse; gemessen rund
|
||
174 MB RSS gegen 291 MB ohne Angaben. Nach einem GC leben ~45 MB. Zu wenig
|
||
Luft? `BACKEND_XMX` in `.env`.
|
||
- **Master-Passwort:** `<BACKEND_DIR>/env` auf dem Server, Modus 600. Ohne
|
||
Hash bleibt `GET /documents` gesperrt.
|
||
- **Datenbank:** H2 im Dateimodus unter `<BACKEND_DIR>/data/`. Ein Deploy
|
||
fasst das Verzeichnis nicht an (kein `--delete`). Umstieg auf das dort
|
||
laufende PostgreSQL: JDBC-URL in der `application.yaml` tauschen und den
|
||
Treiber ergänzen — Schema und Code bleiben.
|
||
- **Nachsehen:** `systemctl --user status werkbaum-backend`,
|
||
`tail -f <BACKEND_DIR>/backend.log`.
|
||
|
||
Begründungen: docs/DECISIONS.md D77 (und D76-Nachträge 1–3 zur Vermessung der
|
||
Zielumgebung).
|
||
|
||
## Hinweise
|
||
|
||
- Versionsnummern in `build.gradle.kts` (Spring Boot, OpenAPI Generator,
|
||
Cucumber, MockK) beim ersten Build ggf. auf den aktuellen Patch-Stand heben.
|