Files
werkbaum/backend/docs/live-editing-proposal.md
T
mhoennig 0446e3d81e initial backend: Dokumente, Historie, API und Persistenz (D76)
- Liquibase-Schema (`document`, `document_history`) + Rollback-scripts
- Spring Boot with JPA-repositories, entities and services
- REST-API (`/documents`, `/documents/{id}`, `/documents/{id}/history`)
- OpenAPI-specifikation for CRUD-Operationen and history
- config files (`application.yaml`, `Liquibase`, H2 im PostgreSQL-Modus)
- preps for future live-editing/delta-updates
- Exceptions for conflikt- and not-found cases (409/404)
- keeping document hostory even after `delete` for RESTORE functionality
2026-08-26 12:56:27 +02:00

8.1 KiB
Raw Blame History

Proposal: Live-Editing über HTTP (Variante „Simpel")

Status: Entwurf zur Diskussion — noch nichts implementiert.

Ziel und Rahmenbedingungen

  • Mehrere Clients (Werkbaum-Web-App/PWA) arbeiten am selben Dokument: ca. 10 Beobachter, davon 23 gelegentliche Editoren, praktisch nie in derselben Sekunde.
  • Nur HTTP, kein WebSocket. Wenige, sparsame Requests (Lehre aus den aggressiven Rate-Limits der Etherpad-Integration).
  • Kein Neuladen des Dokuments im Normalbetrieb: Clients erhalten Zeilen-Diffs und wenden sie lokal an, damit Cursor/Scrollposition erhalten bleiben.
  • Das Werkbaum-Format ist zeilenorientiert; Zeilen-IDs (#id) sind optional und identifizieren Knoten, nicht Zeilen. Das Protokoll arbeitet deshalb ausschließlich auf physischen Zeilen und braucht keine IDs.
  • Bei echtem Gleichzeitig-Konflikt: Update ablehnen, der Client entscheidet (rebase, neu laden, verwerfen). Das Dokument darf nie kaputtgehen.

Grundidee in einem Satz

Jede Dokumentänderung ist ein zeilenbasiertes Diff gegen eine Basisversion; der Server akzeptiert es nur, wenn die Basisversion noch die aktuelle ist (Optimistic Locking auf Dokumentebene), und verteilt akzeptierte Diffs über Long Polling an alle Beobachter.

Datenmodell: das Zeilen-Diff

Ein Diff ist eine Liste von Operationen relativ zur Basisversion. Zeilen werden über ihren Index in der Basisversion adressiert (0-basiert); Operationen sind nach Index aufsteigend sortiert und überlappen nicht.

{
  "baseVersion": 41,
  "ops": [
    { "op": "replace", "index": 12, "count": 1,
      "lines": ["  - [~] Backend (L) @ben"] },
    { "op": "insert",  "index": 20,
      "lines": ["  + [?] Dark mode (S)"] },
    { "op": "delete",  "index": 25, "count": 2 }
  ]
}
  • replace: count Zeilen ab index werden durch lines ersetzt.
  • insert: lines werden vor index eingefügt (index == Zeilenanzahl = anhängen).
  • delete: count Zeilen ab index entfallen.

Warum Indizes statt Inhalts-Hashes reichen: Der Server kennt die Basisversion vollständig (Historie speichert Snapshots). Version + Index ist damit eindeutig — auch bei identischen Zeilen (Leerzeilen!). Ein optionales "checksum"-Feld (Hash des Gesamtdokuments der Basisversion) dient nur als Integritätsprüfung gegen Client-Bugs.

Werkbaum-Besonderheiten sind damit automatisch abgedeckt: Fortsetzungszeilen ( \), "-Beschreibungszeilen und der ----Beschreibungsteil sind schlicht physische Zeilen. Halbfertige Zwischenzustände rendert Werkbaum mit Warnung weiter — zeilenweise Updates sind hier risikoarm.

API-Erweiterung (OpenAPI-Spec)

1. PATCH /documents/{id}/content — Änderung einreichen

Request: das Diff-Objekt oben.

  • 200 OK: akzeptiert. Antwort: { "version": 42 } (neue Version). Der Server wendet das Diff an, inkrementiert die Dokumentversion, schreibt einen Historieneintrag (ChangeType UPDATED).

  • 409 Conflict: baseVersion ist nicht mehr aktuell. Antwort enthält alles, was der Client zum Weiterarbeiten braucht — ohne Neuladen:

    {
      "currentVersion": 43,
      "opsSinceBase": [ ...Diff von baseVersion  currentVersion... ]
    }
    

    Der Client entscheidet:

    • Rebase: fremde Ops lokal anwenden; überlappen sie nicht mit den eigenen Änderungen, eigene Ops auf neue Indizes verschieben und erneut senden. Deckt den häufigsten Fall („jemand hat weiter oben editiert") ohne Nutzerinteraktion ab.
    • Konflikt anzeigen: bei Überlappung Nutzer fragen (übernehmen / verwerfen / manuell mergen).
  • 404: Dokument gelöscht (Restore-Hinweis in der Problem-Detail-Antwort).

  • 422: Diff nicht anwendbar (Index außerhalb, Checksum-Fehler) — deutet auf einen Client-Bug, Client sollte neu laden.

PUT /documents/{id} bleibt als „Ganzdokument ersetzen" bestehen (Import, Reparatur), wertet aber künftig expectedVersion aus.

2. GET /documents/{id}/changes?since={version}&wait={seconds} — Änderungsfeed

Long Polling, der Kern der „Echtzeit ohne WebSocket"-Lösung:

  • Gibt es bereits Änderungen nach since: sofort 200 mit

    {
      "fromVersion": 41,
      "currentVersion": 43,
      "ops": [ ...kumuliertes Diff 41  43... ],
      "events": [ { "version": 43, "changeType": "UPDATED" } ]
    }
    
  • Sonst hält der Server die Anfrage bis zu wait Sekunden offen (Empfehlung: 25 s, unterhalb üblicher Proxy-Timeouts). Kommt in der Zeit eine Änderung, antwortet er sofort; sonst 204 No Content, und der Client pollt erneut.

  • Latenz: praktisch sofort. Kosten: 1 offene HTTP-Anfrage pro Beobachter, ~2,4 Requests/Minute im Leerlauf — rate-limit-freundlich, PWA-tauglich, kein WebSocket nötig.

  • DELETED/RESTORED erscheinen als Events im Feed, damit Beobachter auch Löschung/Wiederherstellung live mitbekommen.

Client-Schleife eines Beobachters:

loop:
  antwort = GET /changes?since=meineVersion&wait=25
  wenn 200: ops lokal anwenden, meineVersion = currentVersion,
            Cursor-Indizes um Verschiebungen oberhalb korrigieren
  wenn 204: weiter

Warum Long Polling und nicht SSE?

Server-Sent Events wären die Alternative (eine dauerhafte Verbindung, Push vom Server). Long Polling gewinnt hier, weil es (a) reines Request/Response-HTTP ist — trivial mit unseren Cucumber-Tests testbar, (b) keinerlei Sonderbehandlung in Proxies/PWA-Service-Workern braucht und (c) bei 10 Beobachtern der Effizienzunterschied irrelevant ist. Ein späterer Umstieg auf SSE oder WebSocket ändert nur den Feed-Endpunkt; Diff-Format und Konfliktlogik bleiben identisch.

Serverseitige Umsetzung

  • Diff anwenden: Snapshot der Basisversion aus der Historie laden (bzw. aktueller Stand, wenn baseVersion == currentVersion, der Normalfall), Ops anwenden, als neue Version speichern.
  • Diff berechnen (für 409-Antwort und Feed): Zeilen-Diff zwischen zwei Snapshots aus der Historie (Standard-Algorithmus, z. B. Myers über java.util-nahe Bibliothek oder eigene simple Implementierung). Alternativ können eingereichte Ops pro Version direkt mitgespeichert werden — Optimierung, kein Muss für v1.
  • Long Polling: Spring MVC DeferredResult + ein In-Process-Notifier (pro Dokument eine Warteliste; notifyAll bei akzeptiertem Update). Kein zusätzliches Framework nötig.
  • Serialisierung: Updates pro Dokument strikt sequenziell (Locking pro Dokument-UUID), damit Versionsprüfung + Anwenden atomar sind.
  • Historie: unverändert Snapshots; das Keyframe/Kompressions-Schema aus der Speicher-Evaluation ist eine spätere, unabhängige Optimierung hinter dem DocumentHistoryRepository-Interface.

Grenzen der simplen Variante (bewusst akzeptiert)

  • Konflikterkennung auf Dokumentebene: Zwei Editoren, die gleichzeitig verschiedene Stellen ändern, erzeugen formal einen Konflikt — der Rebase-Mechanismus in der 409-Antwort löst das aber in der Praxis transparent. Erst wenn das nicht reicht, lohnt Konflikt­prüfung pro Zeilenbereich (die 409-Struktur bleibt dabei gleich).
  • Kein Präsenz-Feature (wer ist online, fremde Cursor). Später über ein leichtgewichtiges presence-Feld im Feed nachrüstbar; das !!!-Fokusmark des Formats kann dafür genutzt werden.
  • Clientseitige Verschlüsselung: Das Protokoll transportiert Zeilen als opake Strings und funktioniert unverändert mit Ciphertext pro Zeile — nur das serverseitige Diff-Berechnen entfiele dann (Clients müssten Ops immer selbst liefern; die Struktur erlaubt das bereits).

Teststrategie

  • Cucumber: „Client B sieht die Änderung von Client A im Feed", „Patch mit veralteter Basisversion liefert 409 mit opsSinceBase", „Feed meldet DELETED", „Rebase-Fall: nicht überlappende Änderung nach 409 erneut einreichen".
  • Unit-Tests: Diff-Anwendung (alle drei Ops, Randfälle: leeres Dokument, Anhängen, letzte Zeile), Diff-Berechnung, Index-Verschiebung.

Vorschlag Umsetzungsreihenfolge

  1. Diff-Modell + Anwenden/Berechnen als reine Kotlin-Funktionen (Unit-Tests)
  2. PATCH /content inkl. 409-Antwort (Spec + Cucumber)
  3. GET /changes mit Long Polling (Spec + Cucumber)
  4. Client-Anpassung (Feed-Schleife, lokales Anwenden, Rebase)