docs: add plan for collaborative note editing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
Server-authoritative operation log with per-block versions and 20s block leases, conflict copies instead of data loss, IndexedDB queue for offline use, HTTP-based sync with WebSocket/SSE/long-poll wakeups, unified note/folder permissions, and per-user undo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
106a34d260
commit
af578d8237
2 files changed
+294
No files matched your search
@@ -18,6 +18,7 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code
|
||||
| [reverse-proxy.md](reverse-proxy.md) | Nginx, Traefik, Caddy, Cloudflare |
|
||||
| [theming.md](theming.md) | Themes und Design-Variablen |
|
||||
| [flaws.md](flaws.md) | Bekannte Schwachstellen und technische Schulden |
|
||||
| [plans/kollaboration-notizen.md](plans/kollaboration-notizen.md) | Plan: gemeinsames Bearbeiten von Notizen |
|
||||
|
||||
## Überblick in 60 Sekunden
|
||||
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
# Plan: Gemeinsames Bearbeiten von Notizen
|
||||
|
||||
Status: Entschieden, bereit zur Umsetzung · Stand 28.10.2026
|
||||
|
||||
## 1. Ziel und Anforderungen
|
||||
|
||||
Mehrere Personen bearbeiten dieselbe Notiz gleichzeitig. Verbindlich:
|
||||
|
||||
1. **Kein Datenverlust.** Was jemand getippt hat, steht danach in der Notiz, auch nach Verbindungsabbruch, Neuladen oder gleichzeitigem Bearbeiten desselben Blocks.
|
||||
2. **Funktioniert ohne WebSocket.** Hinter Firmen-Proxys, die WebSockets blocken oder Streams puffern, muss die Bearbeitung voll funktionieren, nur mit etwas mehr Verzögerung.
|
||||
3. **Funktioniert bei schlechtem Netz.** Mobil mit Abbrüchen, Wechsel zwischen WLAN und Mobilfunk, App im Hintergrund: Eingaben werden lokal gesichert und später zuverlässig übertragen.
|
||||
4. **Klare Regel bei Konflikten.** Bearbeiten zwei Personen denselben Bullet, bekommt eine das Recht. Der Text der anderen geht nicht verloren.
|
||||
5. **Berechtigungen.** Notizen und Ordner lassen sich als *Bearbeiter* oder *Leser* teilen. Der Server setzt das bei jeder Änderung durch.
|
||||
|
||||
Ausgangslage: Der Editor sendet alle 800 ms das ganze Dokument (`PUT /documents/{id}`), der Server ersetzt alle Blöcke. Zwei gleichzeitige Bearbeiter überschreiben sich dabei vollständig. Die vorhandenen Ansätze (CRDT-Reste, `PATCH /blocks`, erzwungenes Nur-Lesen) werden **nicht** weiterverwendet. Es gibt keine Produktivdaten, das Datenmodell wird neu aufgesetzt.
|
||||
|
||||
## 2. Entscheidung
|
||||
|
||||
**Server-autoritatives Operations-Log mit Versionen pro Block und Block-Sperren (Leases).** Die Übertragung läuft immer über gewöhnliche HTTP-Anfragen; WebSocket, SSE und Long-Polling dienen nur als „Weckruf“.
|
||||
|
||||
| Baustein | Aufgabe |
|
||||
|---|---|
|
||||
| **Operations-Log** | Jede Änderung ist eine kleine Operation (Text eines Blocks ändern, Block einfügen, verschieben, löschen). Der Server prüft, wendet an und vergibt eine fortlaufende Nummer pro Dokument (`seq`). Wer `seq` kennt, holt sich einfach alles danach. |
|
||||
| **Version pro Block** | Jede Operation nennt die Blockversion, auf der sie basiert. Passt sie nicht mehr, liegt ein Konflikt vor, und der Server erkennt ihn sicher. |
|
||||
| **Lease (Sperre) pro Block** | Wer in einen Bullet klickt, bekommt ihn für 20 Sekunden reserviert und verlängert das, solange er tippt. Andere sehen „Anna bearbeitet …“ und können diesen Bullet so lange nicht ändern, alle anderen aber schon. Das verhindert fast alle Konflikte. |
|
||||
| **Konfliktkopie** | Kommt es trotzdem zum Konflikt (z. B. Lease abgelaufen, weil jemand offline war), behält der Block die zuerst gespeicherte Fassung. Der andere Text wird als eigener Block direkt darunter eingefügt und als Konflikt markiert. Nichts geht verloren. |
|
||||
| **Lokale Warteschlange** | Jede Änderung landet sofort in IndexedDB und wird von dort gesendet. Jede Operation hat eine eindeutige ID; doppeltes Senden ist harmlos (idempotent). |
|
||||
| **Transport-Stufen** | Daten fließen immer über `POST /sync`. Benachrichtigung über WebSocket → SSE → Long-Polling → Intervall-Abfrage, je nachdem, was durchkommt. |
|
||||
|
||||
### Warum nicht CRDT (Yjs/Automerge) oder OT
|
||||
|
||||
- **CRDT** führt Zeichen für Zeichen zusammen, auch gleichzeitiges Tippen im selben Satz. Dafür braucht das Go-Backend eine vollständige Yjs-Implementierung, um Rechte pro Block, Hashtags und Markdown serverseitig auszuwerten; die gibt es in Go nicht ausgereift. Ohne sie wäre der Server nur ein Speicher für Binärdaten, den er nicht prüfen kann. Der frühere Versuch ist genau daran gescheitert.
|
||||
- **OT** braucht einen fehlerfreien Transformations-Kern für jede Operationsart; das ist schwer korrekt zu bekommen und schwer zu testen.
|
||||
- Du willst ausdrücklich, dass bei einem Bullet **einer das Recht bekommt**. Das passt zu Sperren plus Versionsprüfung: einfach zu verstehen, vollständig serverseitig prüfbar und mit Tests gegen Netzfehler absicherbar.
|
||||
- Bewusster Verzicht: Zwei Personen können nicht gleichzeitig im **selben** Bullet tippen. Gleichzeitig in **verschiedenen** Bullets, auch im selben Ast, funktioniert.
|
||||
|
||||
## 3. Datenmodell (neu)
|
||||
|
||||
Die bisherigen Tabellen `documents`, `blocks`, `block_tags`, `doc_shares`, `doc_team_shares`, `workspace_folder_shares` und die Spalten `blocks_json`, `content` werden ersetzt.
|
||||
|
||||
```sql
|
||||
CREATE TABLE documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
title_version INTEGER NOT NULL DEFAULT 0,
|
||||
icon TEXT NOT NULL DEFAULT '📄',
|
||||
shared_folder_id TEXT, -- falls in einem geteilten Ordner
|
||||
head_seq INTEGER NOT NULL DEFAULT 0, -- letzte vergebene Operationsnummer
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE blocks (
|
||||
id TEXT PRIMARY KEY, -- vom Client erzeugt (UUID), damit offline einfügbar
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
parent_id TEXT, -- NULL = oberste Ebene; Baum statt "level"
|
||||
order_key TEXT NOT NULL, -- Fractional Index, sortiert Geschwister
|
||||
type TEXT NOT NULL DEFAULT 'bullet',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
props TEXT NOT NULL DEFAULT '{}', -- checked, collapsed, lang, conflictOf …
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_by TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT -- Grabstein statt hartem Löschen
|
||||
);
|
||||
CREATE INDEX idx_blocks_doc ON blocks(doc_id, parent_id, order_key);
|
||||
|
||||
CREATE TABLE doc_ops ( -- das Log
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
seq INTEGER NOT NULL,
|
||||
op_id TEXT NOT NULL, -- vom Client, eindeutig → Idempotenz
|
||||
user_id TEXT NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, -- set_content | set_props | insert | move | delete | set_title
|
||||
block_id TEXT,
|
||||
base_version INTEGER,
|
||||
payload TEXT NOT NULL, -- JSON inkl. alter und neuer Werte
|
||||
status TEXT NOT NULL, -- applied | conflict | rejected
|
||||
result TEXT NOT NULL DEFAULT '{}', -- z. B. ID der Konfliktkopie
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (doc_id, seq)
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_doc_ops_op ON doc_ops(op_id);
|
||||
|
||||
CREATE TABLE block_tags (tag TEXT, doc_id TEXT, block_id TEXT, PRIMARY KEY(tag, block_id));
|
||||
|
||||
CREATE TABLE workspace_access ( -- ein Freigabemodell für Notizen und Ordner
|
||||
id TEXT PRIMARY KEY,
|
||||
resource_type TEXT NOT NULL CHECK(resource_type IN ('document','folder')),
|
||||
resource_id TEXT NOT NULL,
|
||||
principal_type TEXT NOT NULL CHECK(principal_type IN ('user','team')),
|
||||
principal_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('viewer','editor')),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(resource_type, resource_id, principal_type, principal_id)
|
||||
);
|
||||
```
|
||||
|
||||
Entscheidungen dazu:
|
||||
|
||||
- **Baum mit `parent_id` statt flacher Liste mit `level`.** Einrücken oder Verschieben eines Astes ist dann *eine* Operation auf *einem* Block; die Unterpunkte wandern automatisch mit. Mit der flachen Liste ergeben gleichzeitige Einrückungen leicht kaputte Strukturen.
|
||||
- **Fractional Index (`order_key`).** Ein neuer Block zwischen `a0` und `a1` bekommt `a0V`; es muss nie umnummeriert werden. Gleiche Schlüssel (zwei gleichzeitige Einfügungen) werden über die Block-ID eindeutig sortiert.
|
||||
- **Grabsteine.** Gelöschte Blöcke bleiben mit `deleted_at` erhalten. So kann eine verspätete Textänderung an einem gelöschten Block den Block wiederherstellen statt verloren zu gehen. Aufräumen nach 30 Tagen.
|
||||
- **Leases liegen im Arbeitsspeicher**, nicht in der Datenbank. Sie sind kurzlebig (20 s), und nach einem Neustart fordern Clients sie automatisch neu an. MindSift läuft als eine Instanz, das passt.
|
||||
- **Abgeleitete Daten** (`block_tags`, Markdown für Export und Suche) werden aus den geänderten Blöcken in derselben Transaktion bzw. bei Bedarf erzeugt.
|
||||
|
||||
## 4. Protokoll
|
||||
|
||||
### 4.1 Endpunkte
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/docs/{id}/snapshot` | Alle lebenden Blöcke, Titel, `headSeq`, eigene Rolle, aktuelle Leases |
|
||||
| POST | `/api/v1/docs/{id}/sync` | **Ein Aufruf für alles**: eigene Operationen senden, Leases halten/abgeben, neue Operationen abholen |
|
||||
| GET | `/api/v1/docs/{id}/changes?since=N&wait=25` | Long-Polling: antwortet sofort, wenn `headSeq > N`, sonst nach höchstens 25 s mit leerem Ergebnis |
|
||||
|
||||
Anfrage an `/sync`:
|
||||
|
||||
```json
|
||||
{
|
||||
"clientId": "tab-7f3…",
|
||||
"lastSeq": 412,
|
||||
"ops": [
|
||||
{ "opId": "c1a…", "kind": "set_content", "blockId": "b9…", "baseVersion": 7, "content": "Milch und Brot" },
|
||||
{ "opId": "c1b…", "kind": "insert", "blockId": "bA…", "parentId": "b2…", "orderKey": "a0V", "type": "bullet", "content": "" }
|
||||
],
|
||||
"leases": { "hold": ["b9…"], "release": ["b4…"] }
|
||||
}
|
||||
```
|
||||
|
||||
Antwort:
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{ "opId": "c1a…", "status": "applied", "seq": 413, "version": 8 },
|
||||
{ "opId": "c1b…", "status": "applied", "seq": 414 }
|
||||
],
|
||||
"ops": [ /* alle Operationen mit seq > 412, auch fremde */ ],
|
||||
"headSeq": 415,
|
||||
"leases": { "granted": ["b9…"], "denied": [], "others": [{ "blockId": "b3…", "user": "Anna" }] },
|
||||
"presence": [{ "userId": "…", "name": "Anna", "blockId": "b3…" }]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Regeln auf dem Server
|
||||
|
||||
Alle Operationen eines Dokuments laufen nacheinander: ein Mutex pro Dokument im Prozess und eine SQLite-Transaktion pro `/sync`-Aufruf. Dadurch gibt es eine eindeutige Reihenfolge, und „wer zuerst kommt“ ist immer eindeutig.
|
||||
|
||||
1. **Idempotenz:** Ist `opId` schon im Log, wird das gespeicherte Ergebnis zurückgegeben, ohne erneut anzuwenden. Verlorene Antworten sind dadurch harmlos.
|
||||
2. **Berechtigung:** Ohne Rolle `editor` oder `owner` wird jede Operation abgelehnt (`403`).
|
||||
3. **Zugehörigkeit:** Block und Elternblock müssen zu diesem Dokument gehören (behebt FLAW-11).
|
||||
4. **`set_content`:**
|
||||
- Hält ein *anderer* Client eine gültige Lease → **Konflikt**.
|
||||
- `baseVersion == version` → anwenden, `version + 1`.
|
||||
- `baseVersion < version` → **Konflikt**.
|
||||
- Block gelöscht → wiederherstellen (am alten Platz; ist der Elternblock auch gelöscht, unter dem nächsten lebenden Vorfahren) und anwenden.
|
||||
5. **Konflikt:** Der Block behält seinen Inhalt. Der Server fügt direkt danach einen neuen Block mit dem abgelehnten Text ein, `props.conflictOf = <blockId>`, `props.conflictAuthor = <Name>`. Ergebnis `status: "conflict"` mit der neuen Block-ID.
|
||||
6. **`move`:** Wird abgelehnt, wenn ein Zyklus entstünde (Block unter seinen eigenen Nachfahren) oder ein anderer Client eine Lease auf dem Block hält. Der Client lädt dann den Stand neu.
|
||||
7. **`delete`:** Wird abgelehnt, wenn ein anderer Client gerade einen Block in diesem Ast bearbeitet (Lease). Sonst Grabstein für den ganzen Ast.
|
||||
8. **`insert`:** Ist der Elternblock gelöscht, wird unter dem nächsten lebenden Vorfahren eingefügt.
|
||||
9. **Leases:** Vergabe nur, wenn frei, abgelaufen oder schon im Besitz desselben Clients; Gültigkeit 20 s ab letzter Bestätigung. Der Client sendet alle 7 s einen `/sync` mit seinen gehaltenen Blöcken (Herzschlag).
|
||||
|
||||
### 4.3 Ablauf beim Tippen
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Client Anna
|
||||
participant S as Server
|
||||
participant B as Client Ben
|
||||
A->>S: /sync leases.hold=[b9]
|
||||
S-->>A: granted b9
|
||||
S--)B: Weckruf headSeq=413
|
||||
B->>S: /sync lastSeq=412
|
||||
S-->>B: leases.others: b9 = Anna (Bullet gesperrt)
|
||||
A->>S: /sync op set_content b9 base=7
|
||||
S-->>A: applied v8, seq 414
|
||||
S--)B: Weckruf headSeq=414
|
||||
B->>S: /sync lastSeq=413
|
||||
S-->>B: ops [set_content b9 v8]
|
||||
```
|
||||
|
||||
## 5. Client
|
||||
|
||||
Neues Modul `frontend/src/lib/docSync/` (ersetzt das Autosave in `DocumentEditor.tsx`):
|
||||
|
||||
- **`SyncEngine` pro geöffnetem Dokument:**
|
||||
- *Bestätigter Stand*: Blöcke wie vom Server zuletzt gemeldet, plus `lastSeq`.
|
||||
- *Warteschlange*: eigene, noch nicht bestätigte Operationen.
|
||||
- *Angezeigter Stand* = bestätigter Stand + Warteschlange darüber angewendet. Kommen fremde Operationen herein, wird der bestätigte Stand aktualisiert und die Warteschlange erneut darübergelegt.
|
||||
- **Persistenz:** Warteschlange und bestätigter Stand liegen in IndexedDB. Jede Eingabe wird dort gesichert, bevor sie gesendet wird. Nach Neuladen, Absturz oder vom Handy beendetem Tab wird die Warteschlange erneut gesendet; die Operations-IDs verhindern doppelte Anwendung.
|
||||
- **Tippen:** Textänderungen eines Blocks werden 300 ms gesammelt. Pro Block ist höchstens eine Textoperation unterwegs; weitere Änderungen werden in die nächste zusammengefasst. So bleibt `baseVersion` immer eindeutig.
|
||||
- **Senden:** immer nur ein `/sync` gleichzeitig. Das hält die Reihenfolge der eigenen Operationen stabil. Wiederholung mit Backoff (1 s, 2 s, 4 s … max. 30 s).
|
||||
- **Leases:**
|
||||
- Beim Fokussieren eines Bullets wird die Lease angefordert. Bis zur Antwort darf getippt werden.
|
||||
- Wird sie verweigert: Der Bullet wird schreibgeschützt („Ben bearbeitet gerade“), schon Getipptes wird als neuer Block darunter eingefügt.
|
||||
- Nach 60 s ohne Eingabe oder beim Verlassen des Bullets gibt der Client die Lease frei.
|
||||
- **Mobil und Hintergrund:** Bei `visibilitychange` (Seite verborgen) und `pagehide` sendet der Client die Warteschlange mit `fetch(…, { keepalive: true })` und gibt Leases frei. Bei `online` und beim Zurückkehren in die App synchronisiert er sofort.
|
||||
- **Anzeige:**
|
||||
- Gesperrte Bullets zeigen dezent Avatar und Namen.
|
||||
- Konfliktblöcke sind farbig markiert, mit den Aktionen „Übernehmen“ (ersetzt den Originaltext) und „Verwerfen“.
|
||||
- Ein Statushinweis zeigt „Offline – 3 Änderungen warten“ bzw. „Gespeichert“.
|
||||
- Leser sehen alles schreibgeschützt und live.
|
||||
|
||||
## 6. Transport: robust auch hinter Proxys
|
||||
|
||||
Grundsatz: **Daten fließen nur über normale HTTP-Anfragen** (`/sync`, `/snapshot`). Push-Kanäle melden nur „Dokument X hat jetzt `headSeq` N“. Fällt jeder Push-Kanal aus, funktioniert die Bearbeitung trotzdem, nur mit Verzögerung.
|
||||
|
||||
| Stufe | Erkennung eines Ausfalls | Wechsel zu |
|
||||
|---|---|---|
|
||||
| 1. WebSocket (`/api/v1/ws`) | Kein Aufbau in 3 s oder 2 verpasste Pongs | SSE |
|
||||
| 2. SSE (`/api/v1/events`) | Erstes Ereignis kommt nicht innerhalb von 5 s an (Proxy puffert) | Long-Polling |
|
||||
| 3. Long-Polling (`/changes?wait=25`) | Wiederholte Fehler oder Zeitüberschreitungen | Intervall-Abfrage |
|
||||
| 4. Intervall-Abfrage | – | alle 3 s bei offener Notiz, alle 30 s sonst |
|
||||
|
||||
Unabhängig von der Stufe:
|
||||
- Herzschlag-`/sync` alle 7 s, solange eine Lease gehalten wird. Das ist zugleich eine Abfrage.
|
||||
- Sicherheitsabfrage alle 30 s, auch wenn der Push-Kanal angeblich steht.
|
||||
- Der Client merkt sich die zuletzt funktionierende Stufe und versucht WebSocket alle 5 Minuten erneut.
|
||||
|
||||
Serverseitig nötig:
|
||||
- Räume nur nach Berechtigungsprüfung betreten (behebt FLAW-01 und FLAW-02).
|
||||
- Schreib-Timeout für Streams aufheben (behebt FLAW-15). Long-Polling bleibt mit 25 s unter üblichen Proxy-Grenzen (Nginx 60 s, Cloudflare 100 s).
|
||||
|
||||
## 7. Berechtigungen
|
||||
|
||||
- Rollen je Notiz: `owner` (Eigentümer), `editor`, `viewer`. Freigaben über `workspace_access` an Benutzer oder Gruppen, für eine Notiz oder einen geteilten Ordner. Eine Ordnerfreigabe gilt für alle Notizen darin.
|
||||
- **Effektive Rolle** = höchste aus Eigentümer, direkter Freigabe, Gruppenfreigaben und Ordnerfreigaben. Eine zentrale Funktion `docRole(userID, docID)` im Backend, benutzt von Snapshot, Sync, Long-Polling, Raumbeitritt und Export.
|
||||
- Nur der Eigentümer verwaltet Freigaben und löscht die Notiz. Bearbeiter haben sonst volle Rechte: Inhalt, Struktur, Titel und Symbol. Der Titel wird wie ein Block behandelt (eigene Version, Lease, Konfliktkopie als Block am Anfang der Notiz).
|
||||
- Im Freigabe-Dialog wird „Bearbeiten“ für Notizen und Ordner wieder auswählbar (`allowRoleSelection` statt `fixedPermission="viewer"` in `WorkspaceView.tsx`).
|
||||
- Rechteentzug wirkt sofort: Der nächste `/sync` liefert `403`. Der Client verwirft die Warteschlange nicht, sondern bietet an, die Änderungen als neue eigene Notiz zu sichern.
|
||||
|
||||
## 8. Nicht geteilte Notizen
|
||||
|
||||
Es gibt nur ein Modell; das alte `PUT` mit Komplettersatz entfällt auch für private Notizen. Gründe: kein zweiter Speicherweg, Schutz vor gegenseitigem Überschreiben zwischen eigenen Geräten und Tabs, Offline-Sicherung über die lokale Warteschlange.
|
||||
|
||||
Damit private Notizen keinen unnötigen Aufwand erzeugen, liefert der Server im Snapshot und in jeder `/sync`-Antwort `shared` (gibt es Freigaben?) und `sessions` (wie viele Clients haben die Notiz gerade offen). Solange `shared = false` und `sessions = 1`:
|
||||
|
||||
- keine Leases, also auch kein Herzschlag,
|
||||
- kein Live-Raum, keine regelmäßige Abfrage außer der 30-s-Sicherheitsabfrage,
|
||||
- kürzere Aufbewahrung im Log (7 Tage statt 30), weil niemand alte Einträge nachholen muss.
|
||||
|
||||
Öffnet ein zweites Gerät die Notiz oder wird sie geteilt, bekommen alle offenen Clients das mit der nächsten Antwort mit und schalten auf den vollen Ablauf um (gleicher Code, nur Leases und Push aktiv). Im Vergleich zum heutigen `PUT` (ganzes Dokument alle 800 ms) überträgt und schreibt das neue Modell nur geänderte Blöcke und ist damit bei längeren Notizen sparsamer.
|
||||
|
||||
## 9. Rückgängig und Wiederholen (Strg+Z)
|
||||
|
||||
- **Jeder macht nur seine eigenen Änderungen rückgängig.** `Strg+Z` / `Cmd+Z` macht die letzte eigene Änderung rückgängig, `Strg+Umschalt+Z` bzw. `Strg+Y` wiederholt. Fremde Änderungen bleiben unberührt.
|
||||
- **Umsetzung im Client:** Zu jeder eigenen Operation merkt sich der Client die Gegenoperation (alter Text, alter Platz im Baum, Wiederherstellen statt Löschen). Rückgängig sendet die Gegenoperation als ganz normale Operation über `/sync`. Server, Log und Konfliktregeln brauchen dafür nichts Neues.
|
||||
- **Schritte:** Tippen wird zu Schritten gebündelt (neuer Schritt nach 1 s Pause, beim Wechsel des Bullets oder nach Satzzeichen). Einfügen, Löschen, Einrücken, Verschieben und Abhaken sind jeweils ein Schritt. Ein Einfügen mehrerer Zeilen ist ein Schritt.
|
||||
- **Wenn inzwischen jemand anders den Block geändert hat:** Passt die Version nicht mehr, wird dieser Schritt nicht blind überschrieben. Der Client zeigt „Bullet wurde inzwischen von Anna geändert“ und überspringt ihn. Kein fremder Text geht durch Rückgängig verloren.
|
||||
- **Gesperrte Bullets:** Hält gerade jemand anders die Lease, wartet der Schritt nicht, sondern wird mit demselben Hinweis übersprungen.
|
||||
- **Reichweite:** Der Stapel gilt pro Tab und Notiz, bis zu 200 Schritte, und bleibt beim Wechsel zwischen Notizen erhalten. Nach Neuladen beginnt er leer.
|
||||
- **Native Textfeld-Undo** wird abgefangen (`keydown` und `beforeinput` mit `historyUndo`), sonst würden Browser-Undo und eigenes Undo gegeneinander arbeiten.
|
||||
- **Später möglich:** Eine Verlaufsansicht („Wer hat wann was geändert“, ältere Fassung eines Bullets wiederherstellen) lässt sich direkt aus dem Log bauen. Sie ist nicht Teil dieses Features.
|
||||
|
||||
## 10. Tests (Pflicht für „super robust“)
|
||||
|
||||
1. **Simulation im Go-Backend** (Kern der Absicherung): N simulierte Clients erzeugen zufällige Operationen. Ein simuliertes Netz verliert, verdoppelt, verzögert und vertauscht Anfragen und Antworten; Clients gehen offline und kommen zurück. Nach jedem Lauf wird geprüft:
|
||||
- Alle Clients haben denselben Stand wie der Server (Konvergenz).
|
||||
- Jeder je getippte Endtext eines Clients steht im Dokument, im Block selbst oder in einer Konfliktkopie (kein Verlust).
|
||||
- Der Baum ist gültig: keine Zyklen, keine verwaisten Blöcke.
|
||||
|
||||
Tausende Läufe mit festem Seed in CI.
|
||||
2. **Konfliktmatrix als Unit-Tests:** jede Kombination aus Lease, Version, Grabstein, Verschieben und Löschen.
|
||||
3. **Frontend-Engine mit Vitest:** gefälschter Transport, `fake-indexeddb`, Neuladen mitten im Senden, verlorene Antworten.
|
||||
4. **Rückgängig:** eigene Schritte zurücknehmen und wiederholen, Überspringen bei fremder Änderung, gebündeltes Tippen, Einfügen mehrerer Zeilen als ein Schritt.
|
||||
5. **Ende-zu-Ende mit Playwright:** zwei Browser-Kontexte im selben Dokument. WebSocket per Routing blockiert → Fallback auf SSE und Long-Polling; Offline-Schalter mitten im Tippen; gedrosseltes Netz.
|
||||
|
||||
## 11. Umsetzung in Phasen
|
||||
|
||||
| Phase | Inhalt | Ergebnis |
|
||||
|---|---|---|
|
||||
| 0 | Sicherheitsfixes vorziehen: Raumberechtigung (FLAW-01/02), Stream-Timeout (FLAW-15), zentrale `docRole` | Grundlage dicht |
|
||||
| 1 | Neues Schema, Operations-Log, `/snapshot`, `/sync`, Konfliktregeln, Simulationstests | Server fertig und abgesichert |
|
||||
| 2 | `SyncEngine` mit IndexedDB-Warteschlange, nur Intervall-Abfrage als Transport, Editor umgestellt (Baum statt `level`) | Bearbeiten funktioniert überall, auch offline |
|
||||
| 3 | Leases und Präsenz in UI, Konfliktblöcke mit Übernehmen/Verwerfen, Rückgängig/Wiederholen | Gemeinsames Bearbeiten ohne Kollisionen, Strg+Z |
|
||||
| 4 | Push-Stufen WebSocket → SSE → Long-Polling mit automatischer Erkennung | Live-Gefühl, wo das Netz es zulässt |
|
||||
| 5 | Freigabe-Dialog mit Bearbeiter-Rolle, Ordnerfreigaben, Altlasten entfernen (`PUT`-Blockersatz, `PATCH /blocks`, `doc_shares`, `doc_team_shares`, Zwangsmigration auf „read“) | Feature komplett |
|
||||
| 6 | Härtung: Playwright-Tests, Log-Kompaktierung (Snapshot statt Log für Clients mit sehr altem `lastSeq`), Aufräumen der Grabsteine | Langzeitbetrieb |
|
||||
|
||||
Phase 2 ist bewusst vor den Push-Kanälen: Steht die Bearbeitung über reine HTTP-Abfragen, ist der schwierigste Fall (Proxy blockt alles außer HTTP) von Anfang an abgedeckt. WebSocket und SSE machen es danach nur schneller.
|
||||
|
||||
## 12. Getroffene Entscheidungen
|
||||
|
||||
| Frage | Entscheidung |
|
||||
|---|---|
|
||||
| Lease-Dauer | 20 s, Verlängerung alle 7 s beim Tippen |
|
||||
| Konflikte | Konfliktkopie als eigener Block darunter, mit Übernehmen/Verwerfen. Kein automatisches Zusammenführen. |
|
||||
| Titel | Jeder Bearbeiter darf ihn ändern; Bearbeiter haben außer Freigaben und Löschen volle Rechte. |
|
||||
| Verlauf/Undo | Strg+Z/Wiederholen für eigene Änderungen ist Teil des Features (Phase 3). Eine Verlaufsansicht kommt später. |
|
||||
| Private Notizen | Gleiches Modell, ohne Leases und Live-Kanal, solange nur eine Sitzung offen ist |
|
||||
Reference in new issue
Block a user