Merge remote-tracking branch 'origin/main' into werkator-ci

This commit is contained in:
mhoennig
2026-09-03 12:56:45 +02:00
16 changed files with 1794 additions and 398 deletions
+1 -1
View File
@@ -1 +1 @@
1.1 1.2
+7 -1
View File
@@ -19,7 +19,13 @@ reverse.
## 2026-09-02 ## 2026-09-02
- Running the installed app and a browser tab side by side recorded as planned: documents get their own storage keys so windows stop deleting each other's work, a per-document lock finds the one real loss case, and the second window gets three exits instead of "continue anyway" — spelled out in RFC 002 before it is built - Running the installed app and a browser tab side by side works now: text, names and earlier states live under their own storage key per document and deleting leaves a tombstone, so windows stop deleting each other's work
- A document created, renamed or deleted in another window shows up without a reload now, and a document deleted elsewhere while open here stays until you type — typing brings it back
- The modal "open more than once" dialog is gone; the one remaining loss case — the same non-shared document open in two windows — is found by a per-document lock, and the second window chooses: open another document, view only until the other window lets go, or edit anyway
- The other-window warning now names the document and says whether it is open in the app or in the browser — and stays silent when nothing collides
- Fix: in the "open in another window" dialog the list of other documents squeezed its buttons until the names ran into each other — picking one is a dropdown now, one line tall however many documents you have
- The two-window warning says "whichever change is saved last wins" now instead of "the last keystroke" — folding a branch in the diagram writes to the text too, so it joins the same race
- "View only" really is view only now: while the document is held by another window, the diagram's fold marks stay put too — folding writes to the text and used to slip past the read-only text area; the window holding the document folds as before
- An MCP server for AI agents recorded as planned: it lives in the backend, runs the one JS parser through GraalJS, hands plans to Claude Code and other agents as resources and tools over streamable HTTP, and writes changes as conflict-safe line diffs — no new runtime, spelled out in an RFC before it is built - An MCP server for AI agents recorded as planned: it lives in the backend, runs the one JS parser through GraalJS, hands plans to Claude Code and other agents as resources and tools over streamable HTTP, and writes changes as conflict-safe line diffs — no new runtime, spelled out in an RFC before it is built
## 2026-08-28 ## 2026-08-28
+177 -8
View File
@@ -7747,15 +7747,16 @@ Warnung den Zustand, und die Sicherungen halten den Text. Unabhängig davon
kann `tools/pull-doc --git-commit` (D88) per Cron eine Git-Historie des kann `tools/pull-doc --git-commit` (D88) per Cron eine Git-Historie des
Server-Dokuments führen — ein Netz außerhalb des Browsers. Server-Dokuments führen — ein Netz außerhalb des Browsers.
**Nachtrag — der modale Zwei-Fenster-Dialog wird revidiert (2026-09-02).** **Nachtrag — der modale Zwei-Fenster-Dialog ist weg (revidiert, gebaut 2026-09-02).**
Das erste Netz oben ist in der Sache überholt: Es erschien auch dort, wo Das erste Netz oben war in der Sache überholt: Es erschien auch dort, wo
nichts kollidiert (dasselbe geteilte Dokument in App und Tab — zwei nichts kollidiert (dasselbe geteilte Dokument in App und Tab — zwei
Live-Clients, der Server führt zusammen), und es schützte nicht vor dem, Live-Clients, der Server führt zusammen), und es schützte nicht vor dem,
wovor es warnte — hinter der Overlay-Schicht liefen Start, Flush und Feed wovor es warnte — hinter der Overlay-Schicht liefen Start, Flush und Feed
weiter. Der eigentliche Verlust zwischen zwei Fenstern liegt in den weiter. Der eigentliche Verlust zwischen zwei Fenstern lag in den
Sammel-Schlüsseln der Ablage, nicht im Dialog. Analyse, Alternativen und Sammel-Schlüsseln der Ablage, nicht im Dialog. Analyse, Alternativen und
Entscheidungen: **D94** und `docs/rfc/002-mehrfenster.md`. Die drei Entscheidungen: **D94** und `docs/rfc/002-mehrfenster.md`. Der Präsenz-Kanal
anderen Netze (Sicherungen, Rettung, Wachhund) bleiben. (Herzschlag, Timeout, Notluke) und der modale Dialog sind ersatzlos
ausgebaut; die drei anderen Netze (Sicherungen, Rettung, Wachhund) bleiben.
## D90 — Die Dokumentart steht grau hinter dem Namens-Chip ## D90 — Die Dokumentart steht grau hinter dem Namens-Chip
Nutzerwunsch, unmittelbar aus dem D89-Vorfall: Hinter der Brotkrume Nutzerwunsch, unmittelbar aus dem D89-Vorfall: Hinter der Brotkrume
@@ -8830,7 +8831,7 @@ ungepuffert durch. Fällt er durch, ist die Antwort nicht Node, sondern eine
neue Frage an den Entwickler. Offen sonst nur die Prompts. Gebaut ist neue Frage an den Entwickler. Offen sonst nur die Prompts. Gebaut ist
nichts. nichts.
## D94 — Mehr-Fenster-Betrieb: getrennte Schlüssel, Sperre je Dokument, Dialog mit Auswegen — als RFC vorgelegt, nichts gebaut ## D94 — Mehr-Fenster-Betrieb: getrennte Schlüssel, Sperre je Dokument, Dialog mit Auswegen — gebaut (2026-09-02)
Gemeldet: Werkbaum als installierte App mit einem `?live=`-Dokument, dazu Gemeldet: Werkbaum als installierte App mit einem `?live=`-Dokument, dazu
dieselbe Seite im Browser-Tab — der Tab stellt das zuletzt aktive dieselbe Seite im Browser-Tab — der Tab stellt das zuletzt aktive
(geteilte) Dokument her, und in beiden Fenstern steht sofort der modale (geteilte) Dokument her, und in beiden Fenstern steht sofort der modale
@@ -8918,5 +8919,173 @@ Browser-Bestand (Chrome 69, Firefox 96, Safari 15.4).
im Browser mit zwei Tabs — vor allem der Fall, der heute rot ist: B legt im Browser mit zwei Tabs — vor allem der Fall, der heute rot ist: B legt
ein Dokument an, A wechselt das Dokument, B's Dokument muss überleben. ein Dokument an, A wechselt das Dokument, B's Dokument muss überleben.
Die PWA-Nachstellung selbst bleibt Handtest (D73), ebenso Firefox und Die PWA-Nachstellung selbst bleibt Handtest (D73), ebenso Firefox und
Safari. Werkzeuggrenzen wie in D79/D82/D83/D91-Nachtrag 9 benannt. Gebaut Safari. Werkzeuggrenzen wie in D79/D82/D83/D91-Nachtrag 9 benannt.
ist nichts.
**Gebaut (2026-09-02, vier Schritte wie im RFC §12):**
- **Schema v3** (`docstore.js`): `readDocs` liest die Union aus Meta-, Text-
und Index-ids minus Tombstones; `writeDoc`/`writeIndexHint`/`removeDoc`/
`expireTombstones` ersetzen den Voll-Flush — der Index schreibt sich aus
`Speicher-ids eigene Liste` und entfernt nichts. Migration
(`migrateV3`) verteilt die alten Sammel-Stände und schreibt fehlende
Meta, idempotent. Die Stände liegen je Dokument (`werkbaum-snaps:<id>`,
`snapshots.js`); nur der Quota-Notfall fasst noch einen fremden
Schlüssel an — lesend gekürzt, nie aus dem eigenen Gedächtnis
überschrieben (§6.8).
- **Dirty-Flush** statt Voll-Flush (`app.js`): die Flush-Punkte schreiben
nur, was dieses Fenster angelegt, umbenannt oder getippt hat, plus den
Index-Hinweis. Der Tastendruck bleibt `storeDocText` — er hebt einen
Tombstone selbst (Tippen ist Absicht).
- **`docsync.js` (neu, headless, getestet)** wendet storage-Ereignisse an:
Liste, Namen, Tombstones, Stände-Cache, Reihenfolge-Hinweis; am aktiven
Dokument die drei Fälle umbenannt / anderswo gelöscht (behalten, bis
getippt wird) / fremd geschrieben. Datei-Handles werden bei `meta` neu
lazy aus IndexedDB nachgeladen bzw. bei `deleted` verworfen (§6.9).
- **Web Locks je Dokument** (`ifAvailable`, gehalten, solange aktiv):
Fällt von selbst, wartende Requests werden geweckt. Bekommt ein Fenster
die Sperre nicht, zeigt es den Dialog über dem Editor mit drei Auswegen
(anderes Dokument öffnen / nur ansehen / trotzdem bearbeiten); ohne
Locks-API warnt der storage-Rückfall ohne Dialog. Die `tabConflict`-
Warnung nennt Dokument und Fensterart (aus der eigenen Sicht gefolgert,
`display-mode: standalone`).
- Headless: 666 Tests grün, die Gegenprobe per Mutation gezogen — der
zurückgebaute Sweep lässt genau den benannten Test fallen.
**Nachgemessen im Browser, zwei echte Tabs (2026-09-02).** Die neun Fälle
aus RFC §10, Ergebnisse dort im Einzelnen: **1 bis 8 grün** — B's neues
Dokument überlebt A's Flush (der Test für Befund 2, im Vor-v3-Build per
Worktree gegengeprüft und dort nachweislich rot), beide Kamera-Stände
bleiben nebeneinander, „anderswo gelöscht" behält den Editor und wird durch
Tippen zurückgeholt, der Chip folgt einer fremden Umbenennung, der Dialog
steht nur im zweiten Fenster und dessen „nur ansehen" wird von selbst
beschreibbar, sobald das erste wegwechselt, dasselbe `live:`-Dokument in
beiden Fenstern gibt **keinen** Dialog (das gemeldete Symptom), und bei
wirklich voller Quota weichen die Stände, während die Dokumente bleiben.
**Fall 9 deckte eine Lücke auf — geschlossen.** `reviveGoneDoc()` schrieb
Meta und Text, aber nicht den Index-Hinweis: Ein anderswo gelöschtes und
hier durch Tippen wiederbelebtes Dokument hing bis zum nächsten Flush-Punkt
allein an seinen eigenen Schlüsseln. v3 findet es dort (`readDocs`
vereinigt Meta, Text und Index), ein **Rückbau** auf einen Build vor v3
aber nicht — der liest nur den Index und räumt bei seinem Voll-Flush jeden
Text-Schlüssel ab, den er darin nicht findet. Also stiller Verlust, der
teuerste Fehler (SPEC §4, D59). Die Funktion schreibt den Hinweis jetzt
mit; Gegenprobe per Mutation gezogen — ohne die Zeile bleibt der Index ohne
das Dokument, während Meta und Text dastehen. Sonst heilt sich der Index
von selbst, weil `indexHint()` die Speicher-Schlüssel mitliest.
**Nachtrag — die Auswahl im Sperr-Dialog ist ein Aufklapp-Feld
(2026-09-02).** Gemeldet aus der produktiven Instanz: „Die Buttons mit den
anderen Dokumenten überlappen sich und sind unleserlich." Die Ursache ist
nicht die Zahl der Dokumente, sondern eine Flexbox-Falle: Die Liste war ein
`flex`-Spaltenkasten mit gedeckelter Höhe (9rem), und ihre Knöpfe tragen
serienmäßig `flex-shrink: 1` — statt zu scrollen, schrumpfte der Kasten sie
unter ihre eigene Textzeile. Nachgemessen bei acht Dokumenten: 14,5 px
Kastenhöhe gegen 32 px Inhalt, also 17,5 px Überstand je Knopf bei 4 px
Abstand — die Beschriftungen liefen ineinander.
Zwei Wege standen offen: `flex-shrink: 0` (dann scrollt die Liste wirklich)
oder ein **Aufklapp-Feld**. Entschieden (Nutzer): das Aufklapp-Feld. Es ist
**eine Zeile hoch, unabhängig von der Anzahl** — und der Dialog liegt in
einem Kasten, der auf dem Telefon oder bei kleinem Editor-Panel ohnehin
knapp ist; eine Liste, die mit der Dokumentenzahl wächst, bleibt dort auch
scrollend unhandlich. Die Auswahl aus vielen gleichartigen Dingen ist
zudem genau das, wofür ein Aufklapp-Feld die vertraute Geste ist.
Gebaut mit dem vorhandenen Vokabular: die Kopfzeile ist jetzt ein echtes
`<label for>`, das Feld trägt einen deaktivierten Platzhalter („—"), und
die Auswahl schaltet sofort um — dieselbe Ein-Klick-Geste wie zuvor der
Knopf. **Kein neuer i18n-Schlüssel**: Der Platzhalter ist ein Strich, den
Kontext trägt das vorhandene `docLockOpenOther`.
**Nachgemessen** im Browser bei acht Dokumenten: Feld 36 px hoch, kein
Überstand (2 px), Karte von 364 auf 256 px geschrumpft; die Auswahl
schaltet um, der Dialog schließt, das Textfeld wird beschreibbar. **Benannt,
nicht behoben:** Ist das Editor-Panel sehr flach (gemessen 172 px, weil das
Diagramm die Höhe hat), ist die Karte immer noch höher als der getönte
Grund, in dem sie steckt, und malt darüber hinaus. Lesbar bleibt sie; wen
es stört, deckelt `.lockdlg` mit `max-height`.
**Nachtrag 2 — „der letzte Tastendruck gewinnt" war zu eng gefasst
(2026-09-02).** Nutzer-Rückfrage beim Ausprobieren: „Man kann im Diagramm
auf- und zusammenklappen — das beeinflusst das andere Fenster nicht,
richtig? Und was für ein letzter Tastendruck?" Beides trifft einen
Formulierungsfehler, nicht einen Baufehler:
- **Falten IST eine Änderung.** Umklappen schreibt die Faltmarke in den
Text (D38-Nachtrag 2) und damit in den Speicher — es nimmt am selben
Wettlauf teil wie das Tippen. Nachgemessen: A klappt „Ast A" zu, im
Speicher steht `- > Ast A (M)`; B's Editor und Diagramm bleiben
unverändert (6 Knoten, keine Marke), und B's nächster Tastendruck
überschreibt den Speicher — **A's Faltung ist damit weg**. „Beeinflusst
das andere Fenster nicht" stimmt also für die Anzeige und nicht für den
gespeicherten Text.
- **Die Warnung sagte deshalb das Falsche.** „Der letzte Tastendruck
gewinnt" liest sich, als zähle nur Tippen. Sie heißt jetzt „die zuletzt
**gespeicherte Änderung** gewinnt" (englisch „whichever change is saved
last wins"), in allen neun Sprachen; der Dialogtext nennt das Falten
ausdrücklich mit. SPEC §9 nachgezogen.
- **Der Dokumentwechsel wandert nicht mit** (dieselbe Rückfrage): Ein
anderes Dokument zu wählen ändert nur das eigene Fenster. Der geteilte
Schlüssel `werkbaum-active` wird zwar überschrieben, aber kein Fenster
reagiert darauf — er entscheidet erst wieder, was ein **Neustart**
öffnet (§3, „letzter Schreiber gewinnt, harmlos"). Nachgemessen: A
wechselt auf „Zweites", B bleibt auf „Falt-Probe".
**Dabei aufgefallen, benannt und noch nicht behoben:** Wer „trotzdem hier
bearbeiten" gewählt hat, behält die Warnung, auch wenn das andere Fenster
das Dokument längst verlassen hat — dieser Ausweg fordert die Sperre
bewusst nicht mehr an (`lockGen++`), also erfährt er vom Loslassen nichts.
Sauber wäre, auch dort im Hintergrund zu warten (`waitInBackground`) und
die Warnung zu räumen, sobald die Sperre ankommt — dieselbe Mechanik wie
bei „nur ansehen", nur ohne den Schreibschutz.
**Nachtrag 3 — „nur ansehen" galt nur dem Textfeld; jetzt gilt es dem
Dokument (2026-09-02).** Folgerichtiger Nutzer-Einwand aus Nachtrag 2:
Wenn Falten eine Änderung ist, muss der Nur-Ansehen-Modus auch das
Diagramm stillstellen — „in dem ersten Fenster soll das natürlich weiter
funktionieren."
**Es war schlimmer als eine fehlende Sperre: `readonly` hielt gar nichts
auf.** `replaceTextUndoable` fällt auf `src.value = neu` zurück, wenn
`execCommand` scheitert (D14: „der richtige Zustand geht vor der
Rückgängig-Historie") — und eine Zuweisung an `value` schreibt auch in ein
schreibgeschütztes Feld. Nachgemessen im Nur-Ansehen-Fenster: Ein Klick
auf das Falt-Zeichen setzte `- > Ast A (M)` in Textfeld **und** Speicher.
Der Modus war also nur eine Beschriftung.
**Gesperrt wird jetzt an den Schreibstellen, nicht am Feld.** Eine Zeile
in `replaceTextUndoable` weist im Nur-Ansehen-Modus jeden programmatischen
Schreibzugriff ab — das deckt Falten, Falt-Durchschalter, „aus Taiga
übernehmen", das Laden eines früheren Stands und die ID-Kurzform in einem
Griff. Dazu ruhen die **Gesten** selbst, damit nichts still ins Leere
geht: `toggleFold` und der Durchschalter kehren früh um, die Falt-Zeichen
und der Knopf treten zurück und nehmen keinen Klick an (`body.viewonly`),
und im Knoten-Fenster entfallen die Aktionen, die in die Zeile schreiben —
Story/Task anlegen, Ticket verknüpfen, „übernehmen". **„Nach Taiga
schreiben" bleibt**: Es fasst den Plan nicht an.
**Alles, was nur die Ansicht ändert, bleibt bedienbar** — Zoom, Modus,
Pfad, der Faltzustand aus dem Text, Export, Sprünge. Der Modus sagt
„dieses Dokument nicht ändern", nicht „nichts tun".
**Nachgemessen** mit zwei Fenstern: Im Nur-Ansehen-Fenster lassen Chip,
Tastatur (←) und Durchschalter Text und Speicher unangetastet (5 Knoten
bleiben 5, `pointer-events: none`, Deckkraft 0,45); im haltenden Fenster
faltet dieselbe Geste unverändert und schreibt `> Ast A`; verlässt das
haltende Fenster das Dokument, wird das andere von selbst wieder
beschreibbar **und** faltbar (`> Ast B` geschrieben). Vorher, zur
Gegenprobe: derselbe Klick schrieb im Nur-Ansehen-Modus in den Speicher.
**Benannt, nicht behoben:** Menüwege, die in den Text schreiben (früheren
Stand laden, „Original wiederherstellen"), sind im Nur-Ansehen-Modus zwar
wirkungslos — die Sperre greift —, ihre Einträge stehen aber weiterhin da.
Sie auszugrauen ist der nächste kleine Schritt, wenn es jemanden stört.
**Offen bleibt die Handarbeit** (RFC §10): installierte PWA neben einem Tab,
Firefox, Safari, ein Browser ohne Locks-API. Werkzeuggrenzen wie gehabt —
die Browser-Fläche war verborgen, getippt wurde per `value` + `input`
(D91-Nachtrag 8), gelöscht über die Ablage (`confirm` ist nicht stubbar,
D91-Nachtrag 9), und der Live-Feed ruht im dauerhaft verborgenen
Automatisierungs-Tab (D76-Nachtrag 1) — dass B A's Zeile sieht, ist deshalb
über die Antwort auf den eigenen PATCH gemessen, nicht über den Feed.
+26 -8
View File
@@ -952,14 +952,32 @@ anderen, ohne neu zu laden.
10-Minuten-Takt sie wieder, und das Uhr-Menü zeigt sie als eigenen Abschnitt 10-Minuten-Takt sie wieder, und das Uhr-Menü zeigt sie als eigenen Abschnitt
unter den Server-Meilensteinen. Siehe D89. unter den Server-Meilensteinen. Siehe D89.
**Ein zweites Werkbaum-Fenster desselben Browsers** — Tab, Fenster oder **Ein zweites Werkbaum-Fenster desselben Browsers** — Tab, Fenster oder PWA
PWA — bekommt einen **modalen Dialog**, in beiden Fenstern, bis eines — kollidiert nur noch am **selben nicht geteilten Dokument**: Text, Name und
geschlossen ist: Beide schreiben in dieselbe Dokument-Ablage, der zuletzt frühere Stände liegen je Dokument unter eigenen Schlüsseln, der Index löscht
speichernde überschreibt den anderen. Erkannt per Herzschlag nie, und gelöschte Dokumente hinterlassen einen Tombstone. Verschiedene
(BroadcastChannel); der Dialog schließt sich **von selbst**, sobald das Dokumente in zwei Fenstern arbeiten daher still nebeneinander; ein
andere Fenster zu ist — es ist nichts zu bestätigen. „Trotzdem fortfahren" anderswo angelegtes, umbenanntes oder gelöschtes Dokument zeigt sich im
ist die Notluke und gilt je Fenster und Vorfall. Siehe D89 (und D84 für die laufenden Fenster ohne Neuladen (storage-Ereignis). Für den einen
zeilenlose Warnung, die daneben bestehen bleibt). Verlustfall hält jedes Fenster, solange ein nicht-`live:`-Dokument vorn
ist, eine **Sperre** (Web Locks API) auf es — atomar, und sie fällt von
selbst, wenn das Fenster schließt. Bekommt das zweite Fenster sie nicht,
zeigt es einen Dialog mit drei Auswegen: anderes Dokument öffnen, hier nur
ansehen (**das ganze Dokument ist dann schreibgeschützt**: Textfeld wie
Diagramm — auch die Faltmarken lassen sich dort nicht umklappen, denn das
schriebe in den Text; es wird von selbst wieder beschreibbar, sobald das
andere Fenster loslässt) oder trotzdem hier bearbeiten — dann gewinnt
die **zuletzt gespeicherte Änderung**, und die Warnung nennt das Dokument.
Änderung heißt dabei nicht nur Tippen: Auch das Falten im Diagramm schreibt
in den Text (§9, `>`/`<`) und nimmt an diesem Wettlauf teil. Das andere
Fenster zieht dabei nichts nach — es zeigt weiter seine eigene Fassung —,
und auch das aktive Dokument wechselt dort nicht mit; der gemerkte
Dokumentwähler entscheidet erst wieder, was ein **Neustart** öffnet.
`live:`-Dokumente werden nie gesperrt: Zwei Fenster sind zwei Live-Clients,
der Server führt zusammen. Ohne Web Locks (file://, alte Browser) bleibt
der storage-Rückfall: ein fremder Schreibzugriff am eigenen aktiven
Dokument warnt, ohne Dialog. Siehe D94 (und D89 für die lokalen
Sicherungen und den Wachhund).
Siehe D76 (Protokoll und Begründung) und Siehe D76 (Protokoll und Begründung) und
`backend/docs/live-editing-proposal.md`. `backend/docs/live-editing-proposal.md`.
+5 -5
View File
@@ -76,11 +76,11 @@
- [^] #ed.docs.picker: Breadcrumb picker in the app header (S) %% Werkbaum name, see D81 - [^] #ed.docs.picker: Breadcrumb picker in the app header (S) %% Werkbaum name, see D81
- [^] #ed.docs.url: Load a document from ?sourceUrl= (S) - [^] #ed.docs.url: Load a document from ?sourceUrl= (S)
- [^] #ed.docs.restore: Restore a shipped document from the menu (XS) - [^] #ed.docs.restore: Restore a shipped document from the menu (XS)
- [ ] #ed.docs.windows: Two windows on one storage: app and tab side by side (M) %% docs/rfc/002-mehrfenster.md, D94 - [^] #ed.docs.windows: Two windows on one storage: app and tab side by side (M) %% docs/rfc/002-mehrfenster.md, D94
- [ ] #ed.docs.windows.keys: Every document owns its keys — text, meta, states; the index never deletes (S) - [^] #ed.docs.windows.keys: Every document owns its keys — text, meta, states; the index never deletes (S)
- [ ] #ed.docs.windows.sync: The storage event keeps the list, names and tombstones current in every window (S) - [^] #ed.docs.windows.sync: The storage event keeps the list, names and tombstones current in every window (S)
- [ ] #ed.docs.windows.lock: A per-document lock via the Web Locks API finds the one real loss case (S) :#ed.docs.windows.keys - [^] #ed.docs.windows.lock: A per-document lock via the Web Locks API finds the one real loss case (S) :#ed.docs.windows.keys
- [ ] #ed.docs.windows.dialog: Three exits instead of continue anyway: open another, view only, edit anyway (XS) :#ed.docs.windows.lock - [^] #ed.docs.windows.dialog: Three exits instead of continue anyway: open another, view only, edit anyway (XS) :#ed.docs.windows.lock
- [^] #ed.jump: Jump between diagram and text (S) - [^] #ed.jump: Jump between diagram and text (S)
- [^] #ed.jump.dep: Ctrl+click follows a dependency to its id (XS) - [^] #ed.jump.dep: Ctrl+click follows a dependency to its id (XS)
- [^] #ed.lineno: Line numbers in the text editor (XS) %% the warnings name them - [^] #ed.lineno: Line numbers in the text editor (XS) %% the warnings name them
+63 -1
View File
@@ -2,7 +2,7 @@
| | | | | |
|---------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------| |---------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Status | **Abgestimmt** (sieben Fragen am 2026-09-02, Entscheidungen in §13 und D94) — nichts gebaut | | Status | **Gebaut und im Browser nachgemessen** (2026-09-02, Branch `rfc-002-mehrfenster`; Fälle 18 grün, Fall 9 deckte eine Lücke auf, die geschlossen ist — §10. Offen: die Handarbeit, PWA/Firefox/Safari/ohne Locks) | |
| Anlass | Fehlerbericht: installierte App und Browser-Tab mit demselben geteilten Dokument ⇒ modaler Dialog in beiden Fenstern, einziger Ausgang „Trotzdem fortfahren“ | | Anlass | Fehlerbericht: installierte App und Browser-Tab mit demselben geteilten Dokument ⇒ modaler Dialog in beiden Fenstern, einziger Ausgang „Trotzdem fortfahren“ |
| Plan-Knoten | `#ed.docs.windows` in `docs/examples/werkbaum.werkbaum` | | Plan-Knoten | `#ed.docs.windows` in `docs/examples/werkbaum.werkbaum` |
| Entscheidung | D94 in `docs/DECISIONS.md`; revidiert **D89** (Dialog) und schreibt **D83** (Ablageschema) und **D84** (Fremd-Tab-Warnung) fort | | Entscheidung | D94 in `docs/DECISIONS.md`; revidiert **D89** (Dialog) und schreibt **D83** (Ablageschema) und **D84** (Fremd-Tab-Warnung) fort |
@@ -497,6 +497,59 @@ Inszenieren von Speicherzuständen erst alle alten Tabs schließen (D83).
stubben (D91-Nachtrag 9) — Löschen wird über die Ablage selbst inszeniert. stubben (D91-Nachtrag 9) — Löschen wird über die Ablage selbst inszeniert.
Eine echte PWA-Installation lässt sich nicht automatisieren (D73). Eine echte PWA-Installation lässt sich nicht automatisieren (D73).
### Ergebnis der Nachmessung (2026-09-02)
Zwei echte Tabs auf `localhost:8137`, Fall 6 gegen ein lokal laufendes
Backend. **Grün: 1 bis 8.** Im Einzelnen — Fall 1: A sieht B's neues Dokument
ohne Neuladen, und nach A's Dokumentwechsel samt `visibilitychange`/
`pagehide` sind dessen Text **und** Meta unangetastet; Fall 2: beide
Kamera-Stände liegen nebeneinander, B's Flush lässt A's stehen; Fall 3: Chip
„deleted elsewhere", Editor behält seine 221 Knoten, Tastendruck holt
Dokument und Tombstone zurück, der Wechsel ohne Tastendruck legt den Text
(68 791 Zeichen) in `werkbaum-snaps:werkbaum`; Fall 4: der Chip folgt der
fremden Umbenennung, der Text bleibt; Fall 5: Dialog nur im zweiten Fenster,
„nur ansehen" setzt `readOnly`, und als das erste Fenster wegwechselte, wurde
das zweite **von selbst** beschreibbar — „trotzdem" nennt Dokument und
Fensterart in beiden Fenstern; Fall 6 und 7: dasselbe `live:`-Dokument in
beiden Fenstern gibt **keinen** Dialog, auch beim Start (das gemeldete
Symptom), beide schreiben, der Server zählt von 2 auf 4 und behält beide
Zeilen; Fall 8: mit wirklich voller Quota (kein Stub) steht `storeFailed`,
die Dokumente bleiben unangetastet, die Stände weichen 4 → 0, und der nächste
gelungene Schreibvorgang räumt die Warnung.
**Gegenprobe im Vor-v3-Build gezogen** (git-Worktree am Commit davor, eigener
Dev-Server): Dort ist Fall 1 nachweislich rot — B legt ein Dokument an, A
wechselt das Dokument, und B's Text ist weg (`werkbaum-doc:… = null`, Eintrag
aus dem Index entfernt). Genau Befund 2.
**Fall 9 hat eine Lücke aufgedeckt, die jetzt geschlossen ist.** Alles, was
der Index kennt, ist im alten Build sichtbar — ein Dokument, das gerade nur
an `werkbaum-meta:<id>` und `werkbaum-doc:<id>` hängt, aber nicht im Index
steht, ist dort jedoch nicht nur unsichtbar: Dessen Voll-Flush **löscht**
seinen Text-Schlüssel. Das Fenster dafür ist schmal, weil `indexHint()` die
Speicher-Schlüssel mitliest und sich damit bei jedem Flush selbst heilt —
aber es gab eine Stelle, die es regelmäßig öffnete: `reviveGoneDoc()`
(§6.5) schrieb Meta und Text und **nicht** den Index-Hinweis. Ein anderswo
gelöschtes und hier durch Tippen wiederbelebtes Dokument hing also bis zum
nächsten Flush-Punkt allein an seinen eigenen Schlüsseln. Die Funktion
schreibt den Hinweis jetzt mit. Nachgemessen im Browser: Index nach dem
Tastendruck sofort wieder vollständig; Gegenprobe per Mutation — ohne die
Zeile bleibt der Index ohne das Dokument, während Meta und Text dastehen.
**Zwei Beobachtungen ohne Handlungsbedarf.** Fügen zwei Fenster gleichzeitig
an derselben Stelle eines geteilten Dokuments ein, kann die Zeilenreihenfolge
im Editor kurz von der des Servers abweichen; der nächste Push gleicht sie an,
verloren geht nichts. Und die **Feed-Hälfte** von Fall 6 („beide sehen beides
ohne Neuladen") ist in dieser Umgebung nicht messbar — der Automatisierungs-
Tab ist dauerhaft `hidden`, der Feed ruht dort planmäßig (D76-Nachtrag 1);
gemessen ist stattdessen, dass B A's Zeile über die Antwort auf den eigenen
PATCH bekam. Getippt wurde durchweg per `value` + `input`-Ereignis, weil die
Browser-Fläche verborgen war und echte Tastendrücke nicht ankommen
(D91-Nachtrag 8).
**Offen bleibt die Handarbeit** aus dem Absatz oben: installierte PWA + Tab,
Firefox, Safari, ein Browser ohne Locks-API.
## 11. Abgrenzung — was nicht gebaut wird ## 11. Abgrenzung — was nicht gebaut wird
| Nicht gebaut | Warum | | Nicht gebaut | Warum |
@@ -540,3 +593,12 @@ zurück als heute.
- 2026-09-02 — erste Fassung aus dem Fehlerbericht; sieben Fragen - 2026-09-02 — erste Fassung aus dem Fehlerbericht; sieben Fragen
entschieden; RFC, Plan-Knoten und D94 in einem Commit. Nichts gebaut. entschieden; RFC, Plan-Knoten und D94 in einem Commit. Nichts gebaut.
- 2026-09-02 — gebaut in vier Schritten (§12): Schema v3 + Tombstones +
Dirty-Flush; `docsync.js` + storage-Handler; Web Locks + Dialog + Warnung;
Dokumentation. Headless 666 Tests grün, Gegenprobe per Mutation gezogen;
die Browser-Nachmessungen (§10) standen noch aus.
- 2026-09-02 — im Browser nachgemessen (§10): Fälle 18 grün, Fall 1 im
Vor-v3-Build gegengeprüft und dort rot. Fall 9 deckte auf, dass
`reviveGoneDoc()` den Index-Hinweis nicht mitschrieb — behoben, Gegenprobe
per Mutation gezogen. Offen bleibt die Handarbeit: PWA neben Tab, Firefox,
Safari, ein Browser ohne Locks-API.
+35 -20
View File
@@ -150,31 +150,46 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
auseinander und die PATCH-Antwort gewinnt — wer das prüfen will, muss die auseinander und die PATCH-Antwort gewinnt — wer das prüfen will, muss die
PATCH-Antwort im Client verzögern, sonst meldet die Messung „geht doch". PATCH-Antwort im Client verzögern, sonst meldet die Messung „geht doch".
- Zustand wird im `localStorage` gehalten (Server-Dokumente ausgenommen): `werkbaum-lang` - Zustand wird im `localStorage` gehalten (Server-Dokumente ausgenommen): `werkbaum-lang`
(Sprache), `werkbaum-docs` (JSON-Array der Dokumente `[{id,name,text}]`), (Sprache), `werkbaum-active` (id des aktiven Dokuments), `werkbaum-src` (Spiegel des
`werkbaum-active` (id des aktiven Dokuments), `werkbaum-src` (Spiegel des
aktiven Texts), `werkbaum-ui` (JSON: Modus, verworfene, aktiven Texts), `werkbaum-ui` (JSON: Modus, verworfene,
günstigster Pfad, Split-Zustand inkl. `--col`/`--drow`, Zoom, Vollbild). Neue günstigster Pfad, Split-Zustand inkl. `--col`/`--drow`, Zoom, Vollbild). Neue
GUI-Einstellungen in `saveUI()`/`restoreState()` mitführen; `saveUI` liefert GUI-Einstellungen in `saveUI()`/`restoreState()` mitführen; `saveUI` liefert
während `restoring===true` nichts, damit das Wiederherstellen nicht sofort während `restoring===true` nichts, damit das Wiederherstellen nicht sofort
zurückschreibt. zurückschreibt.
**Persistenz ist geteilt (D82) und je Dokument abgelegt (D83):** Das **Persistenz ist geteilt (D82) und je Dokument abgelegt (D83, v3 nach
Schema liegt headless in `docstore.js``werkbaum-docs` ist nur der RFC 002/D94):** Das Schema liegt headless in `docstore.js`der Text
INDEX `[{id,name,source?}]`, der Text jedes Dokuments ein eigener jedes Dokuments unter `werkbaum-doc:<id>`, sein Name/Quelle/Geburtsdatum
Schlüssel `werkbaum-doc:<id>`. Der Tastendruck schreibt über unter `werkbaum-meta:<id>`, seine früheren Stände unter
`persistActiveText()`/`storeDocText()` NUR den aktiven Text (+ Spiegel `werkbaum-snaps:<id>`, eine Löschung als Tombstone `werkbaum-gone:<id>`
`werkbaum-src` als Rollback-Fallback); den Voll-Flush macht (Verfall 7 Tage). `werkbaum-docs` ist nur der Reihenfolge-Hinweis
`persistDocs()`/`storeDocs()` an Flush-Punkten (Wechseln/Anlegen/Löschen/ `[{id,name,source?}]` — er **entfernt nichts mehr** und wird aus
Umbenennen, `pagehide`, verborgener Tab) — mit Vergleich vor jedem `Speicher-ids eigene Liste` geschrieben (`writeIndexHint`). Der
Schreiben und Abräumen verwaister Text-Schlüssel; nie wieder eine Tastendruck schreibt über `persistActiveText()`/`storeDocText()` NUR den
Voll-Serialisierung in einen Tastendruck-Pfad hängen. Die „Spiegel aktiven Text (+ Spiegel `werkbaum-src`); den **Dirty-Flush** macht
gewinnt"-Regel in `loadDocs` gilt NUR noch der einmaligen Migration aus `persistDocs()` an Flush-Punkten (Wechseln/Anlegen/Löschen/Umbenennen,
dem Altformat (Texte im Array) und muss **vor** `seedShippedDocs()` `pagehide`, verborgener Tab) — er schreibt NUR, was dieses Fenster
laufen — danach drehte der ältere Spiegel eine frisch nachgezogene angelegt, umbenannt oder getippt hat (`dirtyDocs`, docsync.js), **nie**
Fassung zurück und das Dokument gälte für immer als bearbeitet. Ein den Schlüssel eines anderen Fensters (der alte Orphan-Sweep des
fehlender Text-Schlüssel ergibt ein leeres Dokument, nie den Verlust der Voll-Flushes hat fremde Dokumente samt Text gelöscht — genau dagegen ist
Liste. Scheitert ein Write (Quota), meldet die persistente Warnung die Gegenprobe im Test). Die „Spiegel gewinnt"-Regel in `loadDocs` gilt
`storeFailed` (`noteStore()`, rendert nur an der Flanke) — nie wieder NUR noch der einmaligen Migration aus dem Altformat (Texte im Array) und
still schlucken. muss **vor** `seedShippedDocs()` laufen — danach drehte der ältere Spiegel
eine frisch nachgezogene Fassung zurück und das Dokument gälte für immer
als bearbeitet. Ein fehlender Text-Schlüssel ergibt ein leeres Dokument,
nie den Verlust der Liste. Scheitert ein Write (Quota), meldet die
persistente Warnung `storeFailed` (`noteStore()`, rendert nur an der
Flanke) — nie wieder still schlucken.
**Zwei Fenster (RFC 002/D94):** Das `storage`-Ereignis zieht Liste, Namen
und Tombstones per `applyStorageEvent()` (docsync.js, headless getestet)
im laufenden Fenster nach; das aktive anderswo gelöschte Dokument bleibt
warnfarben stehen, bis getippt wird (Tippen belebt es — `storeDocText`
hebt den Tombstone selbst). Ein zweites Fenster am selben **nicht
geteilten** Dokument wird per Web-Locks-Sperre je Dokument erkannt
(`syncDocLock()`/`acquireDocLock()`); der Dialog über dem Editor hat drei
Auswege (anderes öffnen / nur ansehen `viewOnly` / trotzdem bearbeiten);
ohne Locks-API warnt der `foreignWrite`-Rückfall. `live:`-Dokumente
werden nie gesperrt — der Server führt zusammen. Der D89-Präsenz-Kanal
(BroadcastChannel, Herzschlag, modaler Dialog) ist ersatzlos weg.
- Dokumente (D22): mehrere umschaltbare Notationstexte. `loadDocs()` migriert bei - Dokumente (D22): mehrere umschaltbare Notationstexte. `loadDocs()` migriert bei
fehlendem `werkbaum-docs` den bestehenden `werkbaum-src` (oder `INITIAL`) in fehlendem `werkbaum-docs` den bestehenden `werkbaum-src` (oder `INITIAL`) in
**ein** Dokument; `initDocs()` (Aufruf **nach** `applyLang`) holt den aktiven **ein** Dokument; `initDocs()` (Aufruf **nach** `applyLang`) holt den aktiven
+517 -141
View File
File diff suppressed because it is too large Load Diff
+217 -52
View File
@@ -1,49 +1,133 @@
/* Werkbaum Ablageschema der Dokumente im localStorage (D83, headless). /* Werkbaum Ablageschema der Dokumente im localStorage (D83, v3 nach RFC 002,
headless).
Bis D82 lagen alle Dokumente als EIN JSON-Array unter einem Schlüssel Bis D82 lagen alle Dokumente als EIN JSON-Array unter einem Schlüssel
mit zwei strukturellen Folgen: Bei voller Quota scheiterte jeder Flush mit zwei strukturellen Folgen: Bei voller Quota scheiterte jeder Flush
ganz (auch die Änderung an einem winzigen Dokument muss das Ganze ganz, und ein einziger kaputter Schlüssel kostete die Sicht auf ALLE
mitschreiben), und ein einziger kaputter Schlüssel kostete die Sicht auf Dokumente. D83 teilte auf (Index + Text je Dokument) behielt aber den
ALLE Dokumente. Deshalb die Aufteilung: Voll-Flush, der den Index aus der EIGENEN Liste schrieb und jeden
fremden Text-Schlüssel entfernte. Zwei Fenster löschten sich damit
gegenseitig Dokumente und Stände weg (RFC 002, Befund 2 und 3). Schema
v3 zieht die Konsequenz **kein Fenster schreibt je den Schlüssel eines
anderen**:
- `werkbaum-docs` der INDEX: [{id, name, source?}], ohne Text. - `werkbaum-doc:<id>` der Text je Dokument (wie D83).
- `werkbaum-doc:<id>` der Text je Dokument, ein eigener Schlüssel. - `werkbaum-meta:<id>` `{name, source?, born}` je Dokument (NEU):
- `werkbaum-src` Spiegel des aktiven Texts (Rollback-Fallback: Name und Quelle ohne den Index; `born` ordnet
ein älterer Build fällt auf ihn zurück). Dokumente, die der Index nicht kennt.
- `werkbaum-snaps:<id>` frühere Stände je Dokument (snapshots.js).
- `werkbaum-gone:<id>` Tombstone der Löschung, Zeitstempel, Verfall
nach GONE_TTL (§6.2). Gelöscht" ist damit von
nie gesehen" zu unterscheiden.
- `werkbaum-docs` der INDEX bleibt als **Reihenfolge-Hinweis**
und Rollback-Brücke; er löscht NIE mehr und
wird nur aus `Speicher-ids eigene Liste`
geschrieben.
- `werkbaum-src` Spiegel des aktiven Texts (Rollback-Fallback).
Das ALTFORMAT (Texte im Array) wird beim Lesen erkannt und einmalig
aufgeteilt der nächste Voll-Flush schreibt den Index ohne Texte.
Hausregel (D54-Nachtrag 3): Der Storage wird als {getItem, setItem, Hausregel (D54-Nachtrag 3): Der Storage wird als {getItem, setItem,
removeItem} hereingereicht, damit die Tests ihn stellen können. */ removeItem} hereingereicht, damit die Tests ihn stellen können. Schlüssel-
Aufzählungen (Union-Lesen, Tombstone-Verfall) bekommen die Schlüsselliste
als Argument im Browser `Object.keys(localStorage)`. */
export const LS_DOCS = 'werkbaum-docs'; export const LS_DOCS = 'werkbaum-docs';
export const LS_ACTIVE = 'werkbaum-active'; export const LS_ACTIVE = 'werkbaum-active';
export const LS_SRC = 'werkbaum-src'; export const LS_SRC = 'werkbaum-src';
export const DOC_TEXT_PREFIX = 'werkbaum-doc:'; export const DOC_TEXT_PREFIX = 'werkbaum-doc:';
export const DOC_META_PREFIX = 'werkbaum-meta:';
export const DOC_GONE_PREFIX = 'werkbaum-gone:';
export const DOC_SNAPS_PREFIX = 'werkbaum-snaps:';
/* Der alte Sammel-Schlüssel der Stände (D54D83) — nur noch Migration. */
export const LEGACY_SNAPS = 'werkbaum-snaps';
/* Tombstones verfallen nach sieben Tagen (RFC 002 §6.2). */
export const GONE_TTL = 7 * 24 * 60 * 60 * 1000;
export function docTextKey(id){ return DOC_TEXT_PREFIX + id; } export function docTextKey(id){ return DOC_TEXT_PREFIX + id; }
export function docMetaKey(id){ return DOC_META_PREFIX + id; }
export function docGoneKey(id){ return DOC_GONE_PREFIX + id; }
/* Dokumentenliste lesen. Rückgabe {docs, legacy} `legacy` heißt: Das /* Meta eines Dokuments lesen — null, wenn kein brauchbares Objekt liegt. */
Altformat lag (ganz oder teilweise) vor, der nächste Voll-Flush schließt export function readMeta(storage, id){
die Migration ab. `null`, wenn nichts Brauchbares da ist dann greift der let o = null;
Beispiel-/Migrations-Pfad des Aufrufers. try{ o = JSON.parse(storage.getItem(docMetaKey(id)) || 'null'); }catch(_){ return null; }
if(!o || typeof o !== 'object' || Array.isArray(o)) return null;
return o;
}
export function hasTombstone(storage, id){
return storage.getItem(docGoneKey(id)) != null;
}
/* Dokumentenliste lesen (v3). Rückgabe {docs, legacy} `legacy` heißt: Das
Altformat (Texte im Index-Array) lag vor, der Spiegel-Vergleich des
Aufrufers entscheidet noch über das aktive Dokument. `null`, wenn nichts
Brauchbares da ist dann greift der Beispiel-/Migrations-Pfad des
Aufrufers.
Menge der Dokumente = ids mit Meta- oder Text-Schlüssel, vereinigt mit dem
Index, minus ids mit Tombstone. Name aus Meta, sonst Index, sonst die id.
Reihenfolge: Index zuerst, danach Nachzügler nach `born`. Ein Index-Eintrag
ohne Text und ohne Meta ist ein Rest und wird ignoriert (§6.1).
Ein fehlender Text-Schlüssel ergibt einen LEEREN Text, kein Verwerfen des Ein fehlender Text-Schlüssel ergibt einen LEEREN Text, kein Verwerfen des
Dokuments: Der Schaden bleibt auf das eine Dokument begrenzt genau der Dokuments: Der Schaden bleibt auf das eine Dokument begrenzt genau der
Punkt der Aufteilung. */ Punkt der Aufteilung. */
export function readDocs(storage){ export function readDocs(storage, allKeys){
let arr = null; const keys = Array.isArray(allKeys) ? allKeys : [];
try{ arr = JSON.parse(storage.getItem(LS_DOCS) || 'null'); }catch(_){ return null; } /* Tombstones zuerst: gelöschte Dokumente tauchen nicht wieder auf. */
if(!Array.isArray(arr) || !arr.length) return null; const weg = new Set();
if(!arr.every(d => d && typeof d.id === 'string')) return null; for(const k of keys){
const legacy = arr.some(d => typeof d.text === 'string'); if(typeof k === 'string' && k.startsWith(DOC_GONE_PREFIX)) weg.add(k.slice(DOC_GONE_PREFIX.length));
const docs = arr.map(d => { }
const out = { id: d.id, name: typeof d.name === 'string' ? d.name : d.id }; /* Der Index ist nur noch ein Hinweis beschädigt oder leer heißt nicht
if(typeof d.source === 'string') out.source = d.source; nichts da", die Dokumente stehen an ihren eigenen Schlüsseln. */
if(typeof d.text === 'string'){ let index = null;
out.text = d.text; /* Altformat: Text im Array */ try{ index = JSON.parse(storage.getItem(LS_DOCS) || 'null'); }catch(_){}
const eintraege = Array.isArray(index)
? index.filter(d => d && typeof d.id === 'string' && !weg.has(d.id))
: [];
const legacy = eintraege.some(d => typeof d.text === 'string');
const ids = [];
const gesehen = new Set();
for(const e of eintraege){
/* Rest: weder Text noch Meta — ein Eintrag, der nichts mehr sagt. */
if(storage.getItem(docTextKey(e.id)) == null && typeof e.text !== 'string'
&& storage.getItem(docMetaKey(e.id)) == null) continue;
if(!gesehen.has(e.id)){ gesehen.add(e.id); ids.push(e.id); }
}
for(const k of keys){
if(typeof k !== 'string') continue;
let id = null;
if(k.startsWith(DOC_META_PREFIX)) id = k.slice(DOC_META_PREFIX.length);
else if(k.startsWith(DOC_TEXT_PREFIX)) id = k.slice(DOC_TEXT_PREFIX.length);
if(id && !gesehen.has(id) && !weg.has(id)){ gesehen.add(id); ids.push(id); }
}
if(!ids.length) return null;
const pos = new Map(eintraege.map((e, i) => [e.id, i]));
const metaFuer = new Map(ids.map(id => [id, readMeta(storage, id)]));
ids.sort((a, b) => {
const rang = id => {
if(pos.has(id)) return [0, pos.get(id), ''];
const meta = metaFuer.get(id);
return [1, meta && typeof meta.born === 'number' ? meta.born : Number.MAX_SAFE_INTEGER, id];
};
const ra = rang(a), rb = rang(b);
return ra[0] - rb[0] || ra[1] - rb[1] || (ra[2] < rb[2] ? -1 : ra[2] > rb[2] ? 1 : 0);
});
const indexNachId = new Map(eintraege.map(e => [e.id, e]));
const docs = ids.map(id => {
const e = indexNachId.get(id), meta = metaFuer.get(id);
const out = { id };
out.name = (meta && typeof meta.name === 'string' && meta.name)
|| (e && typeof e.name === 'string' && e.name) || id;
const source = (meta && typeof meta.source === 'string' && meta.source)
|| (e && typeof e.source === 'string' && e.source) || null;
if(source) out.source = source;
if(e && typeof e.text === 'string'){
out.text = e.text; /* Altformat: Text im Array */
} else { } else {
const t = storage.getItem(docTextKey(d.id)); const t = storage.getItem(docTextKey(id));
out.text = t == null ? '' : String(t); out.text = t == null ? '' : String(t);
} }
return out; return out;
@@ -51,45 +135,126 @@ export function readDocs(storage){
return {docs, legacy}; return {docs, legacy};
} }
/* Voll-Flush: Index + Texte + verwaiste Text-Schlüssel entfernen. /* Ein EINZELNES Dokument schreiben: Meta + Text, je unter eigenem Schlüssel.
Steht ein Tombstone, wird NICHTS geschrieben außer `typed`: Der
Benutzer hat ausdrücklich getippt, der Tombstone fällt und das Dokument
wird neu angelegt (§6.2/§6.5). `born` bleibt erhalten: die bestehende Meta
gewinnt, beim ersten Anlegen zählt die Uhr.
Vor jedem Schreiben wird verglichen unveränderte Schlüssel werden nicht
angefasst (sonst wäre die Write-Amplification des Ein-Schlüssel-Designs
nur verteilt statt behoben). `allKeys` sind die vorhandenen Schlüssel des
Storage (der Aufrufer reicht `Object.keys(localStorage)`): Texte
gelöschter Dokumente werden darüber abgeräumt, fremde Schlüssel bleiben.
Quota-Fehler werden NICHT gefangen der Aufrufer meldet sie (D82). */ Quota-Fehler werden NICHT gefangen der Aufrufer meldet sie (D82). */
export function storeDocs(storage, docs, allKeys){ export function writeDoc(storage, doc, opts){
const index = JSON.stringify(docs.map(d => { if(!doc || typeof doc.id !== 'string' || !doc.id) return false;
const e = { id: d.id, name: d.name }; const {typed = false, now = Date.now()} = opts || {};
if(d.source) e.source = d.source; const gk = docGoneKey(doc.id);
return e; if(storage.getItem(gk) != null){
if(!typed) return false; /* gelöscht bleibt gelöscht (§6.2) */
storage.removeItem(gk);
}
const alt = readMeta(storage, doc.id) || {};
const meta = {name: typeof doc.name === 'string' ? doc.name : doc.id};
if(doc.source) meta.source = doc.source;
meta.born = typeof alt.born === 'number' ? alt.born
: typeof doc.born === 'number' ? doc.born : now;
const mk = docMetaKey(doc.id), metaJson = JSON.stringify(meta);
if(storage.getItem(mk) !== metaJson) storage.setItem(mk, metaJson);
const tk = docTextKey(doc.id);
if(storage.getItem(tk) !== doc.text) storage.setItem(tk, doc.text);
return true;
}
/* Der Index-Hinweis: Reihenfolge fürs Menü und Rollback-Brücke für ältere
Builds. Er wird NIE aus einer eigenen In-Memory-Liste aufgeräumt"
schreiben, was da ist, entfernen, was nicht (§6.1). */
export function writeIndexHint(storage, eintraege){
const index = JSON.stringify((eintraege || []).map(e => {
const out = {id: e.id, name: e.name};
if(e.source) out.source = e.source;
return out;
})); }));
if(storage.getItem(LS_DOCS) !== index) storage.setItem(LS_DOCS, index); if(storage.getItem(LS_DOCS) !== index) storage.setItem(LS_DOCS, index);
for(const d of docs){ }
const k = docTextKey(d.id);
if(storage.getItem(k) !== d.text) storage.setItem(k, d.text); /* Löschen (und Verlassen): Text, Meta und Stände der id gehen, der Tombstone
} kommt (§6.2). Er schützt die Sitzung des anderen Fensters vor dem stillen
const bleibt = new Set(docs.map(d => docTextKey(d.id))); Wiederanlegen; verfallen tut er nach GONE_TTL. */
export function removeDoc(storage, id, now){
storage.removeItem(docTextKey(id));
storage.removeItem(docMetaKey(id));
storage.removeItem(DOC_SNAPS_PREFIX + id);
storage.setItem(docGoneKey(id), String(now));
}
/* Tombstones altern lassen (beim Laden abgeräumt, §6.2): abgelaufene und
defekte fallen weg, junge bleiben. */
export function expireTombstones(storage, allKeys, now){
for(const k of (allKeys || [])){ for(const k of (allKeys || [])){
if(k.startsWith(DOC_TEXT_PREFIX) && !bleibt.has(k)) storage.removeItem(k); if(typeof k !== 'string' || !k.startsWith(DOC_GONE_PREFIX)) continue;
const ts = Number(storage.getItem(k));
if(!(ts > 0) || now - ts > GONE_TTL) storage.removeItem(k);
} }
} }
/* Gehört ein localStorage-Schlüssel zur Dokument-Ablage? Für die /* Migration (einmalig beim Laden, idempotent, §6.1):
Fremd-Tab-Erkennung (D84): Das storage-Ereignis anderer Tabs nennt den 1. `werkbaum-snaps` (Sammel-Schlüssel, D54D83) auf `werkbaum-snaps:<id>`
Schlüssel nur Dokument-Schlüssel heißen ein anderer Tab schreibt in verteilen und entfernen sonst wirft der Flush des einen Fensters die
dieselbe Liste". */ Stände des anderen weg (Befund 3).
2. Für Dokumente, die der Index kennt, aber keine Meta haben, die Meta aus
dem Index schreiben. Ein Rest ohne Text bleibt ohne Meta.
3. Altformat: Texte, die nur noch im Index-Array stehen, auf eigene
Schlüssel (der nächste flushDocs schreibt den Index ohne Texte).
Zwei Fenster, die das gleichzeitig tun, schreiben identische Werte
harmlos. */
export function migrateV3(storage, now){
const alt = storage.getItem(LEGACY_SNAPS);
if(alt != null){
let o = null;
try{ o = JSON.parse(alt); }catch(_){ o = null; }
if(o && typeof o === 'object' && !Array.isArray(o)){
for(const id of Object.keys(o)){
const liste = Array.isArray(o[id])
? o[id].filter(s => s && typeof s.text === 'string' && typeof s.t === 'number')
: [];
const k = DOC_SNAPS_PREFIX + id;
if(liste.length && storage.getItem(k) == null) storage.setItem(k, JSON.stringify(liste));
}
}
storage.removeItem(LEGACY_SNAPS);
}
let index = null;
try{ index = JSON.parse(storage.getItem(LS_DOCS) || 'null'); }catch(_){}
if(!Array.isArray(index)) return;
for(const e of index){
if(!e || typeof e.id !== 'string' || !e.id) continue;
const hatText = storage.getItem(docTextKey(e.id)) != null || typeof e.text === 'string';
if(!hatText) continue; /* Rest ohne Text ignorieren */
if(storage.getItem(docMetaKey(e.id)) == null){
const meta = {name: typeof e.name === 'string' ? e.name : e.id, born: now};
if(typeof e.source === 'string') meta.source = e.source;
storage.setItem(docMetaKey(e.id), JSON.stringify(meta));
}
if(typeof e.text === 'string' && storage.getItem(docTextKey(e.id)) == null){
storage.setItem(docTextKey(e.id), e.text);
}
}
}
/* Gehört ein localStorage-Schlüssel zur Dokument-Ablage? Das storage-Ereignis
anderer Fenster nennt den Schlüssel nur diese gehören zur Synchronisation
(docsync.js); die übrigen (Ansicht, Sprache, Merker) sind harmlos. */
export function isDocKey(k){ export function isDocKey(k){
const s = String(k == null ? '' : k); const s = String(k == null ? '' : k);
return s === LS_DOCS || s === LS_ACTIVE || s === LS_SRC || s.startsWith(DOC_TEXT_PREFIX); return s === LS_DOCS || s === LS_ACTIVE || s === LS_SRC || s === LEGACY_SNAPS
|| s.startsWith(DOC_TEXT_PREFIX) || s.startsWith(DOC_META_PREFIX)
|| s.startsWith(DOC_GONE_PREFIX) || s.startsWith(DOC_SNAPS_PREFIX);
} }
/* Die Tastendruck-Hälfte: nur der Text EINES Dokuments plus der Spiegel. /* Die Tastendruck-Hälfte: nur der Text EINES Dokuments plus der Spiegel.
Anders als bis D82 schreibt der Tastendruck damit direkt in die echte Tippen ist Absicht (D55-Linie): Steht ein Tombstone, hebt dieser
Ablage eine Spiegel gewinnt"-Regel beim Laden braucht das neue Schema Tastendruck ihn auf das Dokument wird wieder angelegt (§6.5); die Meta
nur noch für die einmalige Migration aus dem Altformat. */ schreibt der Dirty-Flush des Aufrufers. */
export function storeDocText(storage, id, text){ export function storeDocText(storage, id, text){
const gk = docGoneKey(id);
if(storage.getItem(gk) != null) storage.removeItem(gk);
const k = docTextKey(id); const k = docTextKey(id);
if(storage.getItem(k) !== text) storage.setItem(k, text); if(storage.getItem(k) !== text) storage.setItem(k, text);
if(storage.getItem(LS_SRC) !== text) storage.setItem(LS_SRC, text); if(storage.getItem(LS_SRC) !== text) storage.setItem(LS_SRC, text);
+136
View File
@@ -0,0 +1,136 @@
/* docsync Nachziehen im laufenden Fenster (RFC 002 §6.3, headless).
Zwei Fenster teilen sich einen Ursprung und damit den Speicher; das
`storage`-Ereignis feuert nur in den FREMDEN Fenstern und nennt Schlüssel,
alten und neuen Wert für Liste, Namen und Tombstones die vollständige
Information. Diese Funktion entscheidet rein, was ein Ereignis bedeutet;
app.js wendet das Ergebnis an (Menü zeichnen, Chip, Warnung).
Heikel sind die Fälle am AKTIVEN Dokument (§5.3):
- anderswo umbenannt übernehmen, der Chip folgt (Text unberührt);
- anderswo gelöscht behalten, bis getippt wird Tippen ist Absicht
(D55-Linie); sofortiges Wegschalten zöge Text unter der Schreibmarke weg;
- anderswo Text geschrieben nicht nachziehen (es überschriebe das
Getippte), sondern warnen (§6.6) der Restfall dasselbe nicht geteilte
Dokument in beiden Fenstern". Bei `live:` ist der Feed die Quelle:
nichts tun.
headless (D54-Nachtrag 3): keine DOM-, keine Speicher-Zugriffe das
Ereignis kommt als Datenobjekt herein, die Liste als Datenarray. */
import { LS_DOCS, DOC_TEXT_PREFIX, DOC_META_PREFIX, DOC_GONE_PREFIX,
DOC_SNAPS_PREFIX, GONE_TTL } from './docstore.js';
export { GONE_TTL };
/* Geteilte Dokumente (`live:`) werden NIE gesperrt: Zwei Fenster sind zwei
Live-Clients mit eigener Kennung, der Server führt zusammen (D76) genau
der Fall, den der alte D89-Dialog verbot, obwohl er funktioniert. Nicht
lokal", sondern „nicht `live:`" ist das Kriterium (§5.4): URL- und
Datei-Dokumente sind ebenso im Browser wahr. */
export function lockDecision(id){
return !String(id == null ? '' : id).startsWith('live:');
}
/* Der Name der Sperre je Dokument (§6.4). */
export function lockName(id){ return 'werkbaum-doc:' + String(id); }
/* Ist ein Tombstone verfallen? Ohne brauchbaren Zeitstempel gilt er als
verfallen ein Sicherheitsnetz darf nicht zum Datenfriedhof werden. */
export function tombstoneExpired(ts, now){
return !(typeof ts === 'number' && ts > 0) || (now - ts) > GONE_TTL;
}
/* Dirty-Menge (§6.1): was DIESSES Fenster angelegt, umbenannt, getippt hat.
Die Flush-Punkte schreiben nur diese Dokumente plus den Index-Hinweis
kein Voll-Flush mehr, der fremde Schlüssel anfassen könnte. */
export function createDirtySet(){
const s = new Set();
return {
add(id){ if(id) s.add(String(id)); },
has(id){ return s.has(String(id)); },
entries(){ return [...s]; },
clear(){ s.clear(); },
};
}
/* Ein storage-Ereignis auf die Dokumentenliste anwenden.
Rückgabe `{docs, action, id}`: `docs` ist DASSELBE Array, wenn nichts an
der Liste geändert hat (Mutationen am Eintrag Umbenennen laufen auf dem
übergebenen Objekt), sonst ein neues. `action` sagt, was am AKTIVEN
Dokument zu tun ist:
- 'created' meta neu: Dokument hängt in der Liste, Text vom
Aufrufer lazy aus dem Speicher lesen
- 'renamed' Name/Quelle des aktiven Dokuments übernommen (Chip)
- 'deleted' aktives Dokument wurde anderswo gelöscht: BEHALTEN,
warnfarben markieren (§6.5), bis getippt wird
- 'foreignWrite' das aktive nicht-`live:`-Dokument wurde anderswo
geschrieben: der Restfall, Warnung (§6.6)
- 'snaps' Stände-Cache der id verwerfen (§6.3)
- 'order' Reihenfolge-Hinweis übernommen
- null nichts am aktiven Dokument */
export function applyStorageEvent(docs, activeId, ev){
const key = String((ev && ev.key) || '');
const neu = ev ? ev.newValue : null;
if(key.startsWith(DOC_META_PREFIX)){
const id = key.slice(DOC_META_PREFIX.length);
if(!id || neu == null) return {docs, action: null, id: null};
let meta = null;
try{ meta = JSON.parse(neu); }catch(_){ meta = null; }
if(!meta || typeof meta !== 'object' || Array.isArray(meta)) return {docs, action: null, id};
const d = docs.find(x => x.id === id);
if(!d){
const aus = {id, name: typeof meta.name === 'string' && meta.name ? meta.name : id, text: ''};
if(typeof meta.source === 'string' && meta.source) aus.source = meta.source;
return {docs: docs.concat([aus]), action: 'created', id};
}
let geaendert = false;
if(typeof meta.name === 'string' && meta.name && d.name !== meta.name){ d.name = meta.name; geaendert = true; }
if(typeof meta.source === 'string' && meta.source && d.source !== meta.source){ d.source = meta.source; geaendert = true; }
return {docs, action: geaendert && id === activeId ? 'renamed' : null, id};
}
if(key.startsWith(DOC_GONE_PREFIX)){
const id = key.slice(DOC_GONE_PREFIX.length);
if(!id || neu == null) return {docs, action: null, id}; /* Tombstone aufgehoben — das meta-Ereignis folgt */
if(!docs.some(x => x.id === id)) return {docs, action: null, id};
if(id === activeId) return {docs, action: 'deleted', id}; /* behalten, warnfarben (§6.5) */
return {docs: docs.filter(x => x.id !== id), action: null, id};
}
if(key.startsWith(DOC_TEXT_PREFIX)){
const id = key.slice(DOC_TEXT_PREFIX.length);
if(!id || neu == null) return {docs, action: null, id}; /* Entfernen kommt als gone-Ereignis */
const d = docs.find(x => x.id === id);
if(!d) return {docs, action: null, id}; /* das meta-Ereignis legt ihn an */
if(id === activeId){
return lockDecision(id) ? {docs, action: 'foreignWrite', id}
: {docs, action: null, id}; /* der Feed ist die Quelle */
}
d.text = neu; /* Vorschau nachziehen */
return {docs, action: null, id};
}
if(key.startsWith(DOC_SNAPS_PREFIX)){
return {docs, action: 'snaps', id: key.slice(DOC_SNAPS_PREFIX.length)};
}
if(key === LS_DOCS){
/* Reihenfolge-Hinweis (§6.1): übernehmen, wo er etwas sagt. Bekannte ids
rücken in seine Reihenfolge, unbekannte bleiben hinten in ihrer
bisherigen Ordnung. */
let arr = null;
try{ arr = JSON.parse(neu || 'null'); }catch(_){ arr = null; }
if(!Array.isArray(arr)) return {docs, action: null, id: null};
const pos = new Map();
arr.forEach((e, i) => { if(e && typeof e.id === 'string') pos.set(e.id, i); });
if(!pos.size) return {docs, action: null, id: null};
const geordnet = docs.slice().sort((a, b) => {
const ra = pos.has(a.id) ? pos.get(a.id) : arr.length + docs.indexOf(a);
const rb = pos.has(b.id) ? pos.get(b.id) : arr.length + docs.indexOf(b);
return ra - rb;
});
const gleich = geordnet.every((d, i) => d === docs[i]);
return {docs: gleich ? docs : geordnet, action: gleich ? null : 'order', id: null};
}
/* werkbaum-active, werkbaum-src, Ansicht, Merker letzter Schreiber
gewinnt, harmlos (§3). */
return {docs, action: null, id: null};
}
+67 -24
View File
@@ -6,18 +6,29 @@
dafür ist reine Logik sie hing nur an `localStorage`, `Date.now()` und dafür ist reine Logik sie hing nur an `localStorage`, `Date.now()` und
dem `<textarea>` fest und war deshalb nicht prüfbar. dem `<textarea>` fest und war deshalb nicht prüfbar.
Seit RFC 002 (Schema v3) liegen die Stände je Dokument unter einem eigenen
Schlüssel `werkbaum-snaps:<id>` der alte Sammel-Schlüssel ließ jeden
Voll-Flush die Stände des anderen Fensters wegwerfen, die Rettungs-
Sicherungen eingeschlossen (Befund 3). Nur der Quota-Notfall fasst noch
einen fremden Schlüssel an (§6.8, bewusst so belassen: Dokumente gehen
vor Ständen, D54).
Aufteilung wie bei den übrigen Modulen: Hier steht, WAS gilt; in Aufteilung wie bei den übrigen Modulen: Hier steht, WAS gilt; in
app.js bleibt, WOHER die Werte kommen (aktives Dokument, Schreibschutz, app.js bleibt, WOHER die Werte kommen (aktives Dokument, Schreibschutz,
Menü) und WOHIN sie gehen (`localStorage`). Der Speicher wird als Menü) und WOHIN sie gehen (`localStorage`). Der Speicher wird als
`{setItem, removeItem}` hereingereicht, die Uhr als Zahl beides lässt sich `{getItem, setItem, removeItem}` hereingereicht, die Schlüsselliste dazu
im Test stellen. (im Browser `Object.keys(localStorage)`) beides lässt sich im Test
stellen.
Datenform: `{docId: [{t, text}, …]}`, ältester Stand zuerst. */ Datenform: je Schlüssel eine Liste `[{t, text}, …]`, ältester Stand zuerst. */
import { DOC_SNAPS_PREFIX } from './docstore.js';
export const LS_SNAPS = 'werkbaum-snaps';
export const SNAP_EVERY = 10 * 60 * 1000; export const SNAP_EVERY = 10 * 60 * 1000;
export const SNAP_KEEP = 20; export const SNAP_KEEP = 20;
export function snapKey(id){ return DOC_SNAPS_PREFIX + id; }
/* Was aus dem Speicher kommt, ist fremder Text: Es kann von einer älteren /* Was aus dem Speicher kommt, ist fremder Text: Es kann von einer älteren
Fassung stammen, von Hand bearbeitet oder halb geschrieben sein. Alles, was Fassung stammen, von Hand bearbeitet oder halb geschrieben sein. Alles, was
nicht die erwartete Form hat, fällt weg statt später beim Lesen zu nicht die erwartete Form hat, fällt weg statt später beim Lesen zu
@@ -25,15 +36,25 @@ export const SNAP_KEEP = 20;
umbringen. */ umbringen. */
export function parseSnaps(raw){ export function parseSnaps(raw){
let o; let o;
try{ o = JSON.parse(raw || '{}'); } try{ o = JSON.parse(raw || 'null'); }
catch(_){ return {}; } catch(_){ return []; }
if(!o || typeof o !== 'object' || Array.isArray(o)) return {}; if(!Array.isArray(o)) return [];
return o.filter(s => s && typeof s.text === 'string' && typeof s.t === 'number');
}
/* Die Stände EINES Dokuments aus dem Speicher lesen. */
export function readSnapList(storage, id){
return parseSnaps(storage.getItem(snapKey(id)));
}
/* Alle Stände des Speichers lesen beim Laden, aus den Schlüsseln. Ein
Schlüssel ohne brauchbare Liste fällt still weg. */
export function readAllSnaps(storage, allKeys){
const out = {}; const out = {};
for(const id of Object.keys(o)){ for(const k of (allKeys || [])){
const list = o[id]; if(typeof k !== 'string' || !k.startsWith(DOC_SNAPS_PREFIX)) continue;
if(!Array.isArray(list)) continue; const liste = parseSnaps(storage.getItem(k));
const rein = list.filter(s => s && typeof s.text === 'string' && typeof s.t === 'number'); if(liste.length) out[k.slice(DOC_SNAPS_PREFIX.length)] = liste;
if(rein.length) out[id] = rein;
} }
return out; return out;
} }
@@ -60,32 +81,54 @@ export function addSnapshot(snaps, id, text, now, opts){
} }
/* Wirft den ältesten Stand **über alle Dokumente hinweg** weg und sagt, ob /* Wirft den ältesten Stand **über alle Dokumente hinweg** weg und sagt, ob
noch einer da war. Über alle, nicht nur im aktiven Dokument: Wenn der Platz noch einer da war im GEDÄCHTNIS; den Speicher pflegt persistSnaps.
knapp wird, ist das Älteste das Entbehrlichste, gleich zu welchem Dokument Über alle, nicht nur im aktiven Dokument: Wenn der Platz knapp wird, ist
es gehört. */ das Älteste das Entbehrlichste, gleich zu welchem Dokument es gehört. */
export function dropOldestSnap(snaps){ export function dropOldestSnap(snaps){
let id = null, t = Infinity; let id = null, t = Infinity;
for(const k in snaps){ for(const k in snaps){
const l = snaps[k]; const l = snaps[k];
if(l && l.length && l[0].t < t){ t = l[0].t; id = k; } if(l && l.length && l[0].t < t){ t = l[0].t; id = k; }
} }
if(id === null) return false; if(id === null) return null;
snaps[id].shift(); snaps[id].shift();
if(!snaps[id].length) delete snaps[id]; if(!snaps[id].length) delete snaps[id];
return true; return id;
} }
/* Der Platz im Speicher ist geteilt. Läuft er über, sollen die **Dokumente** /* Die Stände eines Dokuments schreiben. Der Platz im Speicher ist geteilt
überleben, nicht ihre Stände deshalb wirft der Fehlerfall Stände weg, bis läuft er über, sollen die **Dokumente** überleben, nicht ihre Stände (D54):
es passt, notfalls alle. Gibt zurück, ob am Ende etwas gespeichert wurde. */ Der älteste Stand geht zuerst, dokumentübergreifend. Dabei wird ein fremder
export function persistSnaps(snaps, store){ Schlüssel nur im Notfall angefasst und dann lesend geändert, nie aus dem
eigenen Gedächtnis überschrieben (§6.8). Gibt zurück, ob etwas gespeichert
wurde. */
export function persistSnaps(storage, snaps, id, allKeys){
for(;;){ for(;;){
try{ store.setItem(LS_SNAPS, JSON.stringify(snaps)); return true; } try{ storage.setItem(snapKey(id), JSON.stringify(snaps[id] || [])); return true; }
catch(_){ catch(_){
if(!dropOldestSnap(snaps)){ const opfer = dropOldestSnap(snaps);
try{ store.removeItem(LS_SNAPS); }catch(_){} if(opfer === null){
/* Nichts mehr da — den eigenen Schlüssel frei geben. */
delete snaps[id];
try{ storage.removeItem(snapKey(id)); }catch(_){}
return false; return false;
} }
if(opfer !== id){
/* Der älteste Stand lag bei einem fremden Dokument dessen Schlüssel
im Speicher nachziehen (lesen, kürzen, schreiben), den Cache hier
verwerfen; beim nächsten Öffnen des Menüs frisch gelesen. */
const fremd = parseSnaps(storage.getItem(snapKey(opfer)));
fremd.shift();
try{
if(fremd.length) storage.setItem(snapKey(opfer), JSON.stringify(fremd));
else storage.removeItem(snapKey(opfer));
}catch(__){
try{ storage.removeItem(snapKey(opfer)); }catch(___){}
}
delete snaps[opfer];
}
/* Eigener Verlust: die Liste schrumpfte im Gedächtnis mit der nächste
Versuch schreibt weniger. */
} }
} }
} }
+47 -7
View File
@@ -1701,9 +1701,10 @@
.docpick[aria-checked="true"] .docitem-where{ color: inherit; opacity: .75; } .docpick[aria-checked="true"] .docitem-where{ color: inherit; opacity: .75; }
/* ---------- Zwei-Fenster-Wächter (D89) ---------- /* ---------- Zwei-Fenster-Wächter (D89) ----------
Modaler Dialog, solange ein zweites lebendes Werkbaum-Fenster desselben Der modale Dialog von D89 ist weg (RFC 002/D94): Er verbot ausgerechnet
Browsers nachweisbar ist (Herzschlag); schließt sich von selbst, sobald den Fall, der funktioniert, und schützte nicht vor dem, wovor er warnte.
das andere Fenster zu ist. Über allem (Konflikt-Band liegt bei 1200). */ Übrig bleibt der Sperren-Dialog (unten) die tabmodal-Klassen tragen
weiter die Taiga-Dialoge. */
.tabmodal-overlay{ .tabmodal-overlay{
position:fixed;inset:0;z-index:1300; position:fixed;inset:0;z-index:1300;
background:rgba(36,52,71,.55); background:rgba(36,52,71,.55);
@@ -1715,12 +1716,51 @@
} }
.tabmodal h2{margin:0 0 10px;font-size:1.05rem;color:var(--line)} .tabmodal h2{margin:0 0 10px;font-size:1.05rem;color:var(--line)}
.tabmodal p{margin:0 0 14px;line-height:1.45} .tabmodal p{margin:0 0 14px;line-height:1.45}
.tabmodal-force{
background:none;border:none;padding:0;cursor:pointer;
color:var(--muted);text-decoration:underline;font:inherit;font-size:.85em;
}
@media print{ .tabmodal-overlay{ display:none; } } @media print{ .tabmodal-overlay{ display:none; } }
/* ---------- Sperren-Dialog (RFC 002/D94, §6.5) ----------
Erscheint nur im Restfall dasselbe nicht geteilte Dokument in beiden
Fenstern. Er liegt über dem Editor, nicht über allem: Diagramm und
Dokumenten-Menü bleiben bedienbar. */
.src-wrap{position:relative}
.lockdlg-overlay{
position:absolute;inset:0;z-index:70;
display:flex;align-items:center;justify-content:center;padding:16px;
background:rgba(36,52,71,.35);
}
.lockdlg{
background:var(--paper);border-radius:10px;max-width:420px;width:100%;
padding:18px 20px;box-shadow:0 12px 40px rgba(0,0,0,.35);
}
.lockdlg h2{margin:0 0 10px;font-size:1.05rem;color:var(--line)}
.lockdlg p{margin:0 0 12px;line-height:1.45}
.lockdlg-listhead{display:block;font-size:.78rem;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);margin:0 0 6px}
/* Aufklapp-Feld statt Knopfliste: eine Zeile hoch, egal wie viele Dokumente
es gibt die Liste schrumpfte ihre Knöpfe sonst unter deren eigene
Textzeile (D94-Nachtrag). */
.lockdlg-pick{
display:block;width:100%;max-width:100%;box-sizing:border-box;margin:0 0 14px;
padding:6px 10px;font:inherit;cursor:pointer;
border:1px solid rgba(36,52,71,.3);border-radius:7px;background:var(--paper);color:var(--ink);
}
.lockdlg-pick:hover{border-color:var(--or)}
.lockdlg-btns{display:flex;justify-content:flex-end;gap:10px;flex-wrap:wrap}
.lockdlg-btns button{
padding:7px 14px;border-radius:7px;font:inherit;cursor:pointer;
border:1px solid rgba(36,52,71,.3);background:none;color:var(--ink);
}
.lockdlg-edit{border-color:var(--warn)!important;color:var(--warn)!important}
@media print{ .lockdlg-overlay{ display:none; } }
/* Nur ansehen" (§6.5 Ausweg 2): das Textfeld trägt seinen Zustand und das
Diagramm mit, denn das Falten schreibt ebenfalls in den Text (D38-Nachtrag 2).
Die Falt-Zeichen und der Falt-Durchschalter treten zurück und nehmen keinen
Klick mehr an; die Sperre selbst sitzt im JS, das hier ist ihr sichtbarer
Teil (D94-Nachtrag 3). Alles Übrige Zoom, Modus, Pfad, Export bleibt
bedienbar: Es ändert die Ansicht, nicht den Plan. */
#src[readonly]{background:rgba(107,122,140,.08);color:var(--muted);cursor:default}
body.viewonly #out .fold{pointer-events:none;opacity:.45}
body.viewonly #foldBtn{pointer-events:none;opacity:.45}
/* ---------- Taiga-Anlege- und Anmeldedialog (D91) ---------- /* ---------- Taiga-Anlege- und Anmeldedialog (D91) ----------
Im tabmodal-Idiom (Overlay + Karte); nur die Innereien sind eigen. */ Im tabmodal-Idiom (Overlay + Karte); nur die Innereien sind eigen. */
.taigadlg .taiga-subject{margin:0 0 12px;font-weight:600} .taigadlg .taiga-subject{margin:0 0 12px;font-weight:600}
+9 -5
View File
@@ -52,10 +52,14 @@
droht beim Neuladen; zeilenlos, steht droht beim Neuladen; zeilenlos, steht
zuoberst und verschwindet, sobald ein zuoberst und verschwindet, sobald ein
Schreiben wieder gelingt Schreiben wieder gelingt
- tabConflict { } ein weiterer Tab schreibt in dieselbe - tabConflict { name, where } dasselbe nicht geteilte Dokument wird
Dokument-Ablage (D84): der letzte Flush in einem anderen Fenster bearbeitet
gewinnt; zeilenlos, bleibt bis zum (RFC 002/D94, der Restfall): der letzte
Neuladen stehen Tastendruck gewinnt; zeilenlos, bleibt
stehen, solange das Dokument aktiv ist.
`where` ist ein i18n-Schlüssel für
in der App" / „im Browser" aus der
Sicht des eigenen Fensters gefolgert
- liveUnsent { min } Änderungen an einem Server-Dokument seit - liveUnsent { min } Änderungen an einem Server-Dokument seit
{min} Minuten nicht angekommen (D89): {min} Minuten nicht angekommen (D89):
sie existieren nur in diesem Fenster; sie existieren nur in diesem Fenster;
@@ -117,7 +121,7 @@ function build(w, t, esc){
case 'storeFailed': case 'storeFailed':
return t('storeFailedWarn'); return t('storeFailedWarn');
case 'tabConflict': case 'tabConflict':
return t('tabConflictWarn'); return t('tabConflictWarn', {name: esc(w.name || ''), where: t(w.where || 'windowKindBrowser')});
case 'liveUnsent': case 'liveUnsent':
return t('liveUnsentWarn', {min: w.min}); return t('liveUnsentWarn', {min: w.min});
case 'liveEnded': case 'liveEnded':
+234 -69
View File
@@ -1,119 +1,284 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { readDocs, storeDocs, storeDocText, docTextKey, isDocKey, import { readDocs, writeDoc, writeIndexHint, removeDoc, expireTombstones,
LS_DOCS, LS_SRC, DOC_TEXT_PREFIX } from '../src/docstore.js'; migrateV3, storeDocText, readMeta, hasTombstone, docTextKey, docMetaKey,
docGoneKey, isDocKey, GONE_TTL,
LS_DOCS, LS_SRC, DOC_TEXT_PREFIX, DOC_META_PREFIX, DOC_GONE_PREFIX,
DOC_SNAPS_PREFIX, LEGACY_SNAPS } from '../src/docstore.js';
/* Storage-Attrappe mit Zähler die Vergleich-vor-Schreiben-Regel ist nur /* Storage-Attrappe mit ZÄHLERN JE SCHLÜSSEL die Hausregel des Schemas v3
über die Zahl der setItem-Aufrufe zu beweisen. */ (kein Fenster schreibt je den Schlüssel eines anderen", RFC 002 §6.1) ist
nur über die Zahl der Zugriffe je Schlüssel zu beweisen. Fiele der alte
Orphan-Sweep des D83-Voll-Flushes zurück, fiele GENAU dieser Test. */
function mem(init){ function mem(init){
const m = new Map(Object.entries(init || {})); const m = new Map(Object.entries(init || {}));
let writes = 0; const schreib = new Map(), loesch = new Map();
return { return {
getItem: k => (m.has(k) ? m.get(k) : null), getItem: k => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => { writes++; m.set(k, String(v)); }, setItem: (k, v) => { schreib.set(k, (schreib.get(k) || 0) + 1); m.set(k, String(v)); },
removeItem: k => { m.delete(k); }, removeItem: k => { schreib.set(k, (schreib.get(k) || 0) - 1); m.delete(k); },
keys: () => [...m.keys()], keys: () => [...m.keys()],
writes: () => writes, schreibFuer: k => schreib.get(k) || 0,
schreibGesamt: () => [...schreib.values()].reduce((a, b) => a + Math.abs(b), 0),
map: m,
}; };
} }
const A = {id: 'example', name: 'Example', text: '- a'}; const A = {id: 'example', name: 'Example', text: '- a'};
const B = {id: 'k1', name: 'Sprint 15', text: '- b', source: 'https://x.example/p'}; const B = {id: 'k1', name: 'Sprint 15', text: '- b', source: 'https://x.example/p'};
const JETZT = 1750000000000;
describe('docstore — Index + Text je Dokument (D83)', () => { function schreiben(s, doc, opts){ return writeDoc(s, doc, {now: JETZT, ...opts}); }
it('Rundreise: storeDocs schreibt, readDocs liest dasselbe zurück', () => { function dokumente(s, keys){ return readDocs(s, keys ?? s.keys()); }
describe('docstore v3 — Union-Lesen (§6.1)', () => {
it('Rundreise: writeDoc schreibt, readDocs liest dasselbe zurück', () => {
const s = mem(); const s = mem();
storeDocs(s, [A, B], s.keys()); schreiben(s, A);
expect(readDocs(s)).toEqual({docs: [A, B], legacy: false}); schreiben(s, B);
expect(dokumente(s)).toEqual({docs: [A, B], legacy: false});
}); });
it('der Index trägt KEINEN Text — der liegt je Dokument unter eigenem Schlüssel', () => { it('Meta trägt Name, Quelle und born — der Text liegt je Dokument unter eigenem Schlüssel', () => {
const s = mem(); const s = mem();
storeDocs(s, [A], s.keys()); schreiben(s, B);
expect(s.getItem(LS_DOCS)).not.toContain('- a'); expect(readMeta(s, B.id)).toEqual({name: B.name, source: B.source, born: JETZT});
expect(s.getItem(docTextKey('example'))).toBe('- a'); expect(s.getItem(LS_DOCS)).toBe(null);
expect(s.getItem(docTextKey(B.id))).toBe('- b');
}); });
it('Altformat (Texte im Array) wird gelesen und als legacy gemeldet', () => { it('Altformat (Texte im Index-Array) wird gelesen und als legacy gemeldet', () => {
const s = mem({[LS_DOCS]: JSON.stringify([A, B])}); const s = mem({[LS_DOCS]: JSON.stringify([A, B])});
const r = readDocs(s); const r = dokumente(s);
expect(r.legacy).toBe(true); expect(r.legacy).toBe(true);
expect(r.docs).toEqual([A, B]); expect(r.docs).toEqual([A, B]);
}); });
it('Union: ein Dokument mit Text-Schlüssel, aber ohne Meta und ohne Index, wird gefunden', () => {
const s = mem();
schreiben(s, A);
s.setItem(DOC_TEXT_PREFIX + 'fremd', '- ein Nachzügler');
const r = dokumente(s);
expect(r.docs.map(d => d.id)).toEqual(['example', 'fremd']);
expect(r.docs[1].name).toBe('fremd');
});
it('Reihenfolge: Index zuerst, Nachzügler nach born', () => {
const s = mem();
schreiben(s, {...A, born: 100}, {now: 100});
schreiben(s, B, {now: 50});
writeIndexHint(s, [{id: 'k1', name: B.name}]);
const r = dokumente(s);
expect(r.docs.map(d => d.id)).toEqual(['k1', 'example']); /* Index ('k1') vor dem Nachzügler */
});
it('Name aus Meta gewinnt vor dem Index-Hinweis', () => {
const s = mem();
schreiben(s, {...A, name: 'Neuer Name'});
writeIndexHint(s, [{id: A.id, name: 'Alter Indexname'}]);
expect(dokumente(s).docs[0].name).toBe('Neuer Name');
});
it('ein Index-Eintrag ohne Text und ohne Meta ist ein Rest und wird ignoriert', () => {
const s = mem({[LS_DOCS]: JSON.stringify([{id: 'ghost', name: 'Gespenst'}, {id: 'd1', name: 'Da'}])});
s.setItem(docTextKey('d1'), '- x');
expect(dokumente(s).docs.map(d => d.id)).toEqual(['d1']);
});
it('ein fehlender Text-Schlüssel kostet nur diesen einen Text, nicht die Liste', () => { it('ein fehlender Text-Schlüssel kostet nur diesen einen Text, nicht die Liste', () => {
const s = mem(); const s = mem();
storeDocs(s, [A, B], s.keys()); schreiben(s, A);
s.removeItem(docTextKey('k1')); schreiben(s, B);
const r = readDocs(s); s.removeItem(docTextKey(B.id));
const r = dokumente(s);
expect(r.docs.map(d => d.id)).toEqual(['example', 'k1']); expect(r.docs.map(d => d.id)).toEqual(['example', 'k1']);
expect(r.docs[1].text).toBe(''); expect(r.docs[1].text).toBe('');
expect(r.docs[0].text).toBe('- a');
}); });
it('kaputter oder unbrauchbarer Index -> null (Beispiel-Pfad des Aufrufers)', () => { it('kaputter oder fehlender Index ist nur ein Hinweis — die eigenen Schlüssel zählen weiter', () => {
expect(readDocs(mem({[LS_DOCS]: '{kaputt'}))).toBe(null); const s = mem({[LS_DOCS]: '{kaputt'});
expect(readDocs(mem({[LS_DOCS]: '"kein Array"'}))).toBe(null); schreiben(s, A);
expect(readDocs(mem({[LS_DOCS]: '[]'}))).toBe(null); expect(dokumente(s).docs.map(d => d.id)).toEqual(['example']);
expect(readDocs(mem({[LS_DOCS]: JSON.stringify([{name: 'ohne id'}])}))).toBe(null); expect(dokumente(mem())).toBe(null);
expect(readDocs(mem())).toBe(null);
});
it('unveränderte Schlüssel werden nicht neu geschrieben', () => {
const s = mem();
storeDocs(s, [A, B], s.keys());
const vorher = s.writes();
storeDocs(s, [A, B], s.keys()); /* nichts geändert */
expect(s.writes()).toBe(vorher);
storeDocs(s, [A, {...B, text: '- b2'}], s.keys()); /* ein Text geändert */
expect(s.writes()).toBe(vorher + 1);
});
it('Texte gelöschter Dokumente werden abgeräumt, fremde Schlüssel bleiben', () => {
const s = mem({'werkbaum-ui': '{}'});
storeDocs(s, [A, B], s.keys());
storeDocs(s, [A], s.keys()); /* B gelöscht */
expect(s.getItem(docTextKey('k1'))).toBe(null);
expect(s.getItem('werkbaum-ui')).toBe('{}');
});
it('storeDocText schreibt Text-Schlüssel UND Spiegel — und nur bei Änderung', () => {
const s = mem();
storeDocText(s, 'example', '- neu');
expect(s.getItem(docTextKey('example'))).toBe('- neu');
expect(s.getItem(LS_SRC)).toBe('- neu');
const vorher = s.writes();
storeDocText(s, 'example', '- neu');
expect(s.writes()).toBe(vorher);
}); });
it('ids mit Doppelpunkten und URLs (live:/url:) tragen als Schlüssel', () => { it('ids mit Doppelpunkten und URLs (live:/url:) tragen als Schlüssel', () => {
const live = {id: 'live:https://w.example/api/v1/documents/abc', name: 'Plan', text: '- x'}; const live = {id: 'live:https://w.example/api/v1/documents/abc', name: 'Plan', text: '- x'};
const s = mem(); const s = mem();
storeDocs(s, [live], s.keys()); schreiben(s, live);
expect(readDocs(s).docs).toEqual([live]); expect(dokumente(s).docs).toEqual([live]);
expect(s.getItem(DOC_TEXT_PREFIX + live.id)).toBe('- x'); expect(s.getItem(DOC_TEXT_PREFIX + live.id)).toBe('- x');
}); });
it('fehlender Name fällt auf die id zurück, Quota-Fehler laufen zum Aufrufer', () => {
const s = mem({[LS_DOCS]: JSON.stringify([{id: 'x'}])});
expect(readDocs(s).docs[0].name).toBe('x');
const voll = {getItem: () => null, setItem: () => { throw new Error('QuotaExceeded'); }, removeItem: () => {}};
expect(() => storeDocs(voll, [A], [])).toThrow();
});
}); });
describe('isDocKey — Fremd-Tab-Erkennung (D84)', () => { describe('docstore v3 — Tombstones (§6.2)', () => {
it('erkennt Index, aktives Dokument, Spiegel und Text-Schlüssel', () => { it('gelöscht bleibt gelöscht: der Tombstone nimmt die id aus dem Lesen', () => {
const s = mem();
schreiben(s, A);
removeDoc(s, A.id, JETZT);
expect(hasTombstone(s, A.id)).toBe(true);
expect(s.getItem(docTextKey(A.id))).toBe(null);
expect(s.getItem(docMetaKey(A.id))).toBe(null);
expect(s.getItem(DOC_SNAPS_PREFIX + A.id)).toBe(null);
expect(dokumente(s)).toBe(null);
});
it('ein Tombstone blockiert das stille Wiederanlegen — Tippen hebt ihn auf', () => {
const s = mem();
schreiben(s, A);
removeDoc(s, A.id, JETZT);
expect(writeDoc(s, {...A, text: '- neu'}, {now: JETZT})).toBe(false); /* Flush schreibt nicht */
expect(hasTombstone(s, A.id)).toBe(true);
expect(writeDoc(s, {...A, text: '- neu'}, {now: JETZT, typed: true})).toBe(true); /* Tippen ist Absicht */
expect(hasTombstone(s, A.id)).toBe(false);
expect(s.getItem(docTextKey(A.id))).toBe('- neu');
});
it('storeDocText (der Tastendruck) hebt den Tombstone selbst', () => {
const s = mem();
removeDoc(s, A.id, JETZT);
storeDocText(s, A.id, '- getippt');
expect(hasTombstone(s, A.id)).toBe(false);
expect(s.getItem(docTextKey(A.id))).toBe('- getippt');
});
it('Tombstones verfallen nach 7 Tagen — junge bleiben, alte und defekte fallen', () => {
const s = mem();
s.setItem(docGoneKey('alt'), String(JETZT - GONE_TTL - 1));
s.setItem(docGoneKey('jung'), String(JETZT - GONE_TTL + 1));
s.setItem(docGoneKey('kaputt'), 'gar kein Datum');
expireTombstones(s, s.keys(), JETZT);
expect(hasTombstone(s, 'alt')).toBe(false);
expect(hasTombstone(s, 'jung')).toBe(true);
expect(hasTombstone(s, 'kaputt')).toBe(false);
});
});
describe('docstore v3 — die Flush-Grenze: fremde Schlüssel werden nie angefasst', () => {
/* DIE Gegenprobe des RFC (§10, Mutation): Baut jemand den Orphan-Sweep des
D83-Voll-Flushes zurück, fällt genau dieser Test der Schaden von damals
(Befund 2: B's Dokument samt Text weg) ist hier als Zähler beweisbar. */
it('persistDocs (writeDoc + writeIndexHint) schreibt nur EIGENE Schlüssel, entfernt KEINEN', () => {
const s = mem();
schreiben(s, A);
/* Der Zustand, der Befund 2 war: das andere Fenster hat ein Dokument
angelegt, das diese Liste nicht kennt. */
const fremd = {id: 'fremdes-plan', name: 'Plan aus Fenster B', text: '- b'};
schreiben(s, fremd);
/* Der Flush dieses Fensters kennt nur A: Dirty-Flush + Index-Hinweis. */
writeDoc(s, A);
writeIndexHint(s, [{id: A.id, name: A.name}]);
expect(s.getItem(docTextKey('fremdes-plan'))).toBe('- b'); /* unberührt … */
expect(s.getItem(docMetaKey('fremdes-plan'))).not.toBe(null); /* … und nicht entfernt */
/* und nichts von B ist verloren: */
expect(dokumente(s).docs.map(d => d.id).sort()).toEqual(['example', 'fremdes-plan']);
});
it('writeDoc schreibt nur Meta und Text des einen Dokuments — Zähler je Schlüssel', () => {
const s = mem();
schreiben(s, A);
const zugriffe = k => s.schreibFuer(k);
const vorA = zugriffe('werkbaum-meta:example') + zugriffe('werkbaum-doc:example');
schreiben(s, A); /* dieselben Inhalte — Vergleich greift */
expect(zugriffe('werkbaum-meta:example') + zugriffe('werkbaum-doc:example')).toBe(vorA);
expect(zugriffe(DOC_META_PREFIX + 'k1')).toBe(0);
expect(zugriffe(DOC_TEXT_PREFIX + 'k1')).toBe(0);
});
it('born bleibt erhalten: Umbenennen dreht die Ablage-Reihenfolge nicht um', () => {
const s = mem();
schreiben(s, {...A, born: 5}, {now: JETZT});
writeDoc(s, {...A, name: 'Umbenannt'}, {now: JETZT + 1000}); /* ohne born im Objekt */
expect(readMeta(s, A.id).born).toBe(5);
});
it('writeIndexHint schreibt den Hinweis — und löscht nichts', () => {
const s = mem({[LS_DOCS]: JSON.stringify([{id: 'a', name: 'x'}])});
writeIndexHint(s, []);
expect(s.getItem(LS_DOCS)).toBe('[]');
});
});
describe('docstore v3 — Migration (§6.1, idempotent)', () => {
it('teilt den alten Stände-Sammel-Schlüssel auf und entfernt ihn', () => {
const s = mem({
[LEGACY_SNAPS]: JSON.stringify({a: [{t: 1, text: 'x'}], b: [{t: 2, text: 'y'}, {t: 'kaputt'}]}),
[DOC_TEXT_PREFIX + 'a']: '- a',
});
migrateV3(s, JETZT);
expect(s.getItem(LEGACY_SNAPS)).toBe(null);
expect(s.getItem(DOC_SNAPS_PREFIX + 'a')).toBe(JSON.stringify([{t: 1, text: 'x'}]));
/* b's Liste bleibt — nur die defekten Einträge fallen. */
expect(s.getItem(DOC_SNAPS_PREFIX + 'b')).toBe(JSON.stringify([{t: 2, text: 'y'}]));
/* Ohne Index gibt es keine Meta-Quelle — 'a' bleibt ein Nachzügler. */
expect(readMeta(s, 'a')).toBe(null);
});
it('zweimal laufen = einmal laufen (gleicher Inhalt, keine zusätzlichen Schreibvorgänge)', () => {
const s = mem({
[LEGACY_SNAPS]: JSON.stringify({a: [{t: 1, text: 'x'}]}),
[LS_DOCS]: JSON.stringify([{id: 'a', name: 'A'}]),
});
migrateV3(s, JETZT);
const snapshot = new Map(s.map);
const n = s.schreibGesamt();
migrateV3(s, JETZT + 1000); /* zweimal: schreibt nichts mehr */
expect(s.schreibGesamt()).toBe(n);
expect([...s.map.entries()]).toEqual([...snapshot.entries()]);
});
it('schreibt Meta nur für Dokumente MIT Text — Reste ohne Text bleiben ohne Meta', () => {
const s = mem({[LS_DOCS]: JSON.stringify([{id: 'da', name: 'Da', text: '- x'}, {id: 'rest', name: 'Rest'}])});
migrateV3(s, JETZT);
expect(readMeta(s, 'da')).toEqual({name: 'Da', born: JETZT});
expect(s.getItem(DOC_TEXT_PREFIX + 'da')).toBe('- x');
expect(readMeta(s, 'rest')).toBe(null);
expect(s.getItem(DOC_TEXT_PREFIX + 'rest')).toBe(null);
});
it('bestehende Meta bleibt stehen (born kommt nicht unter die Migration)', () => {
const s = mem({
[LS_DOCS]: JSON.stringify([{id: 'a', name: 'Alt'}]),
[docMetaKey('a')]: JSON.stringify({name: 'Eigenname', born: 1}),
[DOC_TEXT_PREFIX + 'a']: '- x',
});
migrateV3(s, JETZT);
expect(readMeta(s, 'a')).toEqual({name: 'Eigenname', born: 1});
});
});
describe('storeDocText — Tastendruck-Hälfte (unverändert, plus Tombstone)', () => {
it('schreibt Text-Schlüssel UND Spiegel — und nur bei Änderung', () => {
const s = mem();
storeDocText(s, 'example', '- neu');
expect(s.getItem(docTextKey('example'))).toBe('- neu');
expect(s.getItem(LS_SRC)).toBe('- neu');
const vorher = s.schreibGesamt();
storeDocText(s, 'example', '- neu');
expect(s.schreibGesamt()).toBe(vorher);
});
it('Quota-Fehler laufen zum Aufrufer', () => {
const voll = {getItem: () => null, setItem: () => { throw new Error('QuotaExceeded'); }, removeItem: () => {}};
expect(() => writeDoc(voll, A)).toThrow();
});
});
describe('isDocKey — die Schlüssel der Synchronisation (D84, RFC 002 §6.3)', () => {
it('erkennt Index, aktives Dokument, Spiegel, alte Stände und alle per-Dokument-Schlüssel', () => {
expect(isDocKey(LS_DOCS)).toBe(true); expect(isDocKey(LS_DOCS)).toBe(true);
expect(isDocKey('werkbaum-active')).toBe(true); expect(isDocKey('werkbaum-active')).toBe(true);
expect(isDocKey(LS_SRC)).toBe(true); expect(isDocKey(LS_SRC)).toBe(true);
expect(isDocKey(LEGACY_SNAPS)).toBe(true);
expect(isDocKey(DOC_TEXT_PREFIX + 'live:https://x/api/v1/documents/a')).toBe(true); expect(isDocKey(DOC_TEXT_PREFIX + 'live:https://x/api/v1/documents/a')).toBe(true);
expect(isDocKey(DOC_META_PREFIX + 'example')).toBe(true);
expect(isDocKey(DOC_GONE_PREFIX + 'example')).toBe(true);
expect(isDocKey(DOC_SNAPS_PREFIX + 'example')).toBe(true);
}); });
it('fremde Schlüssel und Ansichts-Zustand sind keine Dokument-Schlüssel', () => { it('fremde Schlüssel und Ansicht-Zustand sind keine Dokument-Schlüssel', () => {
expect(isDocKey('werkbaum-ui')).toBe(false); expect(isDocKey('werkbaum-ui')).toBe(false);
expect(isDocKey('werkbaum-lang')).toBe(false); expect(isDocKey('werkbaum-lang')).toBe(false);
expect(isDocKey('werkbaum-snaps')).toBe(false); expect(isDocKey('werkbaum-seeded')).toBe(false);
expect(isDocKey(null)).toBe(false); expect(isDocKey(null)).toBe(false);
}); });
}); });
+161
View File
@@ -0,0 +1,161 @@
import { describe, it, expect } from 'vitest';
import { applyStorageEvent, lockDecision, lockName, tombstoneExpired,
createDirtySet, GONE_TTL } from '../src/docsync.js';
/* docsync (RFC 002 §6.3): Die Ereignis-Matrix was ein storage-Ereignis im
laufenden Fenster bedeutet. Headless: Liste und Ereignis sind Daten.
Fixture: A ist dieses Fenster. Das Ereignis kommt von Fenster B. */
function apply(docs, activeId, key, newValue, oldValue){
return applyStorageEvent(docs, activeId, {key, newValue, oldValue});
}
const D = (id, name, text) => ({id, name, text: text ?? ''});
describe('applyStorageEvent — die Matrix aus §6.3', () => {
it('meta neu: Dokument hängt in der Liste, ohne Text (lazy)', () => {
const docs = [D('a', 'A', '- a')];
const erg = apply(docs, 'a', 'werkbaum-meta:neu', '{"name":"Neu","born":1}');
expect(erg.docs).toEqual([{id: 'a', name: 'A', text: '- a'}, {id: 'neu', name: 'Neu', text: ''}]);
expect(erg.action).toBe('created');
});
it('meta geändert am FREMDEN Dokument: Name/Quelle übernehmen, kein action', () => {
const docs = [D('a', 'A', '- a'), D('b', 'Alt')];
const erg = apply(docs, 'a', 'werkbaum-meta:b', '{"name":"Neu","born":1}');
expect(erg.docs[0]).toBe(docs[0]); /* dasselbe Array — die Mutation trägt */
expect(erg.docs[1].name).toBe('Neu');
expect(erg.action).toBe(null);
});
it('meta geändert am AKTIVEN Dokument: action „renamed" — der Chip folgt', () => {
const docs = [D('a', 'Alt')];
const erg = apply(docs, 'a', 'werkbaum-meta:a', '{"name":"Neu","born":1}');
expect(erg.action).toBe('renamed');
expect(erg.docs[0].name).toBe('Neu');
});
it('gone neu, id NICHT aktiv: aus der Liste', () => {
const docs = [D('a', 'A', '- a'), D('b', 'B', '- b')];
const erg = apply(docs, 'a', 'werkbaum-gone:b', '123');
expect(erg.docs.map(d => d.id)).toEqual(['a']);
expect(erg.action).toBe(null);
});
it('gone neu, id AKTIV: behalten, action „deleted" — warnfarben, bis getippt wird (§6.5)', () => {
const docs = [D('a', 'A', '- a')];
const erg = apply(docs, 'a', 'werkbaum-gone:a', '123');
expect(erg.docs).toBe(docs); /* behalten */
expect(erg.action).toBe('deleted');
});
it('gone aufgehoben (newValue null): nichts tun — das meta-Ereignis legt an', () => {
const erg = apply([D('a', 'A')], 'a', 'werkbaum-gone:a', null);
expect(erg.action).toBe(null);
});
it('doc geändert, id NICHT aktiv: Text für die Vorschau nachziehen', () => {
const docs = [D('a', 'A', '- a'), D('b', 'B', '- alt')];
const erg = apply(docs, 'a', 'werkbaum-doc:b', '- neu');
expect(erg.docs[1].text).toBe('- neu');
expect(erg.action).toBe(null);
});
it('doc geändert, id AKTIV, kein live: — NICHTS am Text, action „foreignWrite" (§6.6)', () => {
const docs = [D('a', 'A', '- mein Text')];
const erg = apply(docs, 'a', 'werkbaum-doc:a', '- fremder Text');
expect(erg.docs[0].text).toBe('- mein Text'); /* das Getippte bleibt */
expect(erg.action).toBe('foreignWrite');
});
it('doc geändert, id aktiv, live: — nichts, der Feed ist die Quelle', () => {
const docs = [D('live:https://x/d1', 'Geteilt', '- gemeinsam')];
const erg = apply(docs, 'live:https://x/d1', 'werkbaum-doc:live:https://x/d1', '- server');
expect(erg.docs[0].text).toBe('- gemeinsam');
expect(erg.action).toBe(null);
});
it('doc geändert, id unbekannt: ignorieren — das meta-Ereignis legt das Dokument an', () => {
const docs = [D('a', 'A')];
const erg = apply(docs, 'a', 'werkbaum-doc:fremd', '- x');
expect(erg.docs).toBe(docs);
expect(erg.action).toBe(null);
});
it('snaps: action „snaps" — der Stände-Cache der id wird verworfen', () => {
const erg = apply([D('a', 'A')], 'a', 'werkbaum-snaps:b', '[{"t":1,"text":"x"}]');
expect(erg.action).toBe('snaps');
expect(erg.id).toBe('b');
});
it('docs (Index-Hinweis): Reihenfolge übernehmen, unbekannte ids bleiben hinten', () => {
const docs = [D('a', 'A'), D('b', 'B'), D('fremd', 'F')];
const erg = apply(docs, 'a', 'werkbaum-docs', '[{"id":"b","name":"B"},{"id":"a","name":"A"}]');
expect(erg.docs.map(d => d.id)).toEqual(['b', 'a', 'fremd']);
expect(erg.action).toBe('order');
});
it('docs unlesbar oder bereits gleich: nichts', () => {
const docs = [D('a', 'A')];
expect(apply(docs, 'a', 'werkbaum-docs', '{kaputt').docs).toBe(docs);
expect(apply(docs, 'a', 'werkbaum-docs', null).docs).toBe(docs);
});
it('Harmlose Schlüssel (active, Spiegel, Ansicht, Merker): nichts', () => {
const docs = [D('a', 'A')];
for(const k of ['werkbaum-active', 'werkbaum-src', 'werkbaum-ui', 'werkbaum-lang', 'werkbaum-seeded']){
const erg = apply(docs, 'a', k, '1');
expect(erg.docs).toBe(docs);
expect(erg.action).toBe(null);
}
});
});
describe('lockDecision — gesperrt wird nie ein geteiltes Dokument (§6.4)', () => {
it('nicht „live:" wird gesperrt — eigene, URL- und Datei-Dokumente ebenso', () => {
expect(lockDecision('example')).toBe(true);
expect(lockDecision('d12345')).toBe(true);
expect(lockDecision('url:https://x.example/p.werkbaum')).toBe(true);
});
it('live: nie — zwei Fenster sind zwei Live-Clients, der Server führt zusammen', () => {
expect(lockDecision('live:https://w.example/api/v1/documents/abc')).toBe(false);
expect(lockDecision(null)).toBe(true);
});
});
describe('tombstoneExpired — Verfall nach 7 Tagen (§6.2)', () => {
const jetzt = 1750000000000;
it('junge Tombstones bleiben', () => {
expect(tombstoneExpired(jetzt - GONE_TTL + 1, jetzt)).toBe(false);
expect(tombstoneExpired(jetzt, jetzt)).toBe(false);
});
it('nach sieben Tagen fallen sie', () => {
expect(tombstoneExpired(jetzt - GONE_TTL - 1, jetzt)).toBe(true);
});
it('ohne brauchbaren Zeitstempel gilt der Tombstone als verfallen', () => {
expect(tombstoneExpired(undefined, jetzt)).toBe(true);
expect(tombstoneExpired('kaputt', jetzt)).toBe(true);
expect(tombstoneExpired(0, jetzt)).toBe(true);
});
});
describe('createDirtySet — die Flush-Punkte schreiben nur Eigenes (§6.1)', () => {
it('sammelt ids, take räumt auf', () => {
const dirty = createDirtySet();
expect(dirty.entries()).toEqual([]);
dirty.add('a'); dirty.add('b'); dirty.add('a');
expect(dirty.entries()).toEqual(['a', 'b']);
dirty.clear();
expect(dirty.entries()).toEqual([]);
});
it('ignoriert fehlende ids', () => {
const dirty = createDirtySet();
dirty.add(null); dirty.add(undefined); dirty.add('');
expect(dirty.entries()).toEqual([]);
dirty.add('x');
expect(dirty.has('x')).toBe(true);
});
});
+92 -56
View File
@@ -1,9 +1,13 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { LS_SNAPS, SNAP_KEEP, parseSnaps, addSnapshot, dropOldestSnap, import { SNAP_EVERY, SNAP_KEEP, parseSnaps, addSnapshot, dropOldestSnap,
persistSnaps, snapLabel } from '../src/snapshots.js'; persistSnaps, readSnapList, readAllSnaps, snapLabel, snapKey } from '../src/snapshots.js';
import { DOC_SNAPS_PREFIX } from '../src/docstore.js';
/* Frühere Stände (D54). Herausgezogen aus app.js, damit genau das prüfbar /* Frühere Stände (D54). Herausgezogen aus app.js, damit genau das prüfbar
wird, was dort dreimal daneben lag: die Regel, wann ein Stand entsteht. */ wird, was dort dreimal daneben lag: die Regel, wann ein Stand entsteht.
Seit RFC 002 liegt JEDES Dokument unter eigenem Schlüssel der alte
Sammel-Schlüssel ließ den Flush des einen Fensters die Stände des anderen
wegwerfen, die Rettungs-Sicherungen eingeschlossen (Befund 3). */
describe('addSnapshot — Takt und Knopf unterscheiden sich in einer Sache', () => { describe('addSnapshot — Takt und Knopf unterscheiden sich in einer Sache', () => {
/* DER gemeldete Fehler (D54-Nachtrag 2): Dokument geöffnet, nichts geändert, /* DER gemeldete Fehler (D54-Nachtrag 2): Dokument geöffnet, nichts geändert,
@@ -71,10 +75,10 @@ describe('addSnapshot — es bleiben die letzten 20', () => {
}); });
}); });
describe('dropOldestSnap — das Älteste geht zuerst, dokumentübergreifend', () => { describe('dropOldestSnap — das Älteste geht zuerst, dokumentübergreifend (im Gedächtnis)', () => {
it('trifft das älteste über alle Dokumente hinweg', () => { it('trifft das älteste über alle Dokumente hinweg und sagt, welches es war', () => {
const snaps = {a: [{t: 50, text: 'a1'}], b: [{t: 10, text: 'b1'}, {t: 60, text: 'b2'}]}; const snaps = {a: [{t: 50, text: 'a1'}], b: [{t: 10, text: 'b1'}, {t: 60, text: 'b2'}]};
expect(dropOldestSnap(snaps)).toBe(true); expect(dropOldestSnap(snaps)).toBe('b');
expect(snaps.b.map(s => s.text)).toEqual(['b2']); expect(snaps.b.map(s => s.text)).toEqual(['b2']);
expect(snaps.a).toHaveLength(1); expect(snaps.a).toHaveLength(1);
}); });
@@ -85,86 +89,117 @@ describe('dropOldestSnap — das Älteste geht zuerst, dokumentübergreifend', (
expect(snaps).toEqual({}); expect(snaps).toEqual({});
}); });
it('meldet false, wenn nichts mehr da ist', () => { it('meldet null, wenn nichts mehr da ist', () => {
expect(dropOldestSnap({})).toBe(false); expect(dropOldestSnap({})).toBe(null);
expect(dropOldestSnap({a: []})).toBe(false); expect(dropOldestSnap({a: []})).toBe(null);
}); });
}); });
/* Der Speicher ist mit den Dokumenten geteilt. Läuft er über, sollen die /* Der Speicher ist mit den Dokumenten geteilt. Läuft er über, sollen die
Dokumente überleben Stände sind das Nachgeben-Bare. */ Dokumente überleben Stände sind das Nachgeben-Bare. */
describe('persistSnaps — Dokumente gehen vor Ständen', () => { describe('persistSnaps — je Dokument geschrieben, Verdrängung über die Schlüssel', () => {
function speicher(limit){ function speicher(limit){
const s = {daten: null, entfernt: false, versuche: 0}; const m = new Map();
return Object.assign(s, { return {
getItem: k => (m.has(k) ? m.get(k) : null),
setItem(k, v){ setItem(k, v){
s.versuche++; if(String(v).length > limit) throw new Error('QuotaExceededError');
if(v.length > limit) throw new Error('QuotaExceededError'); m.set(k, String(v));
s.daten = v;
}, },
removeItem(){ s.entfernt = true; } removeItem: k => m.delete(k),
}); keys: () => [...m.keys()],
map: m,
};
} }
it('speichert, wenn es passt', () => { it('speichert unter dem Schlüssel des Dokuments, wenn es passt', () => {
const store = speicher(1e6), snaps = {a: [{t: 1, text: 'kurz'}]}; const store = speicher(1e6), snaps = {a: [{t: 1, text: 'kurz'}]};
expect(persistSnaps(snaps, store)).toBe(true); expect(persistSnaps(store, snaps, 'a', store.keys())).toBe(true);
expect(JSON.parse(store.daten)).toEqual(snaps); expect(JSON.parse(store.getItem(snapKey('a')))).toEqual(snaps.a);
expect(store.versuche).toBe(1);
}); });
it('wirft die ältesten Stände weg, bis es passt', () => { it('wirft den ältesten Stand des EIGENEN Dokuments weg, bis der eigene Schlüssel passt', () => {
const snaps = {a: [{t: 1, text: 'x'.repeat(200)}, {t: 3, text: 'y'.repeat(200)}], const snaps = {a: [{t: 1, text: 'x'.repeat(200)}, {t: 3, text: 'y'.repeat(200)}]};
b: [{t: 2, text: 'z'.repeat(200)}]}; const store = speicher(220); /* eine Liste (217 Zeichen) passt, beide nicht */
const store = speicher(300); expect(persistSnaps(store, snaps, 'a', store.keys())).toBe(true);
expect(persistSnaps(snaps, store)).toBe(true);
/* Übrig bleibt der jüngste — t:1 und t:2 fielen in dieser Reihenfolge. */
expect(Object.keys(snaps)).toEqual(['a']);
expect(snaps.a.map(s => s.t)).toEqual([3]); expect(snaps.a.map(s => s.t)).toEqual([3]);
}); });
it('gibt auf und räumt den Schlüssel weg, wenn selbst leer nicht passt', () => { it('greift im Notfall auf den ältesten Stand EINES FREMDEN Dokuments zurück — lesend, §6.8', () => {
const snaps = {a: [{t: 1, text: 'x'}]}; const snaps = {a: [{t: 9, text: 'x'.repeat(200)}, {t: 20, text: 'y'.repeat(200)}],
const store = speicher(1); /* nicht einmal "{}" passt */ b: [{t: 1, text: 'p'.repeat(100)}, {t: 4, text: 'q'.repeat(100)}]};
expect(persistSnaps(snaps, store)).toBe(false); const store = speicher(220);
expect(store.entfernt).toBe(true); store.map.set(snapKey('b'), JSON.stringify(snaps.b)); /* der Zustand des anderen Fensters */
expect(snaps).toEqual({}); expect(persistSnaps(store, snaps, 'a', store.keys())).toBe(true);
/* Der älteste Stand (t:1) war b's sein Schlüssel im Speicher wurde
gekürzt, nicht aus dem Gedächtnis überschrieben; dann räumte der
eigene älteste, bis a passte. */
expect(snaps.a.map(s => s.t)).toEqual([20]);
expect(JSON.parse(store.getItem(snapKey('b'))).map(s => s.t)).toEqual([4]);
expect(snaps.b).toBeUndefined(); /* Cache verworfen — beim nächsten Öffnen frisch gelesen */
}); });
it('speichert unter dem vereinbarten Schlüssel', () => { it('entfernt den fremden Schlüssel ganz, wenn sein letzter Stand gefallen ist', () => {
let key = null; const snaps = {a: [{t: 9, text: 'x'.repeat(200)}, {t: 20, text: 'y'.repeat(200)}],
persistSnaps({}, {setItem(k){ key = k; }, removeItem(){}}); b: [{t: 1, text: 'p'.repeat(100)}]};
expect(key).toBe(LS_SNAPS); const store = speicher(220);
store.map.set(snapKey('b'), JSON.stringify(snaps.b));
expect(persistSnaps(store, snaps, 'a', store.keys())).toBe(true);
expect(store.getItem(snapKey('b'))).toBe(null); /* sein letzter Stand ging */
});
it('gibt auf und räumt den eigenen Schlüssel weg, wenn selbst leer nicht passt', () => {
const snaps = {a: [{t: 1, text: 'x'}]};
const store = speicher(1); /* nicht einmal "[]" passt */
expect(persistSnaps(store, snaps, 'a', store.keys())).toBe(false);
expect(store.getItem(snapKey('a'))).toBe(null);
expect(snaps).toEqual({});
}); });
}); });
/* Was aus dem Speicher kommt, ist fremder Text: alte Fassung, von Hand /* Beim Laden werden die Schlüssel gelesen ein Schlüssel ohne brauchbare
bearbeitet, halb geschrieben. Ein Sicherheitsnetz darf daran nicht die App Liste fällt still weg. */
aufhängen. */ describe('readAllSnaps / readSnapList — die Stände liegen je Dokument', () => {
describe('parseSnaps — beschädigter Speicher bringt die App nicht um', () => { const store = {
it('liest die erwartete Form', () => { getItem: k => ({[snapKey('a')]: '[{"t":1,"text":"x"}]',
const o = {a: [{t: 1, text: 'x'}]}; [snapKey('b')]: '{kaputt',
expect(parseSnaps(JSON.stringify(o))).toEqual(o); [snapKey('c')]: '[]'}[k] ?? null),
};
const keys = [snapKey('a'), snapKey('b'), snapKey('c'), 'werkbaum-ui'];
it('readAllSnaps liest alle Dokumente aus den Schlüsseln', () => {
expect(readAllSnaps(store, Object.keys({})).a).toBeUndefined(); /* ohne Schlüsselliste: nichts */
expect(Object.keys(readAllSnaps(store, [snapKey('a'), snapKey('b'), snapKey('c'), 'werkbaum-ui'])))
.toEqual(['a']);
});
it('readSnapList liest eine Liste, beschädigter Inhalt wird eine leere Liste', () => {
expect(readSnapList(store, 'a')).toEqual([{t: 1, text: 'x'}]);
expect(readSnapList(store, 'b')).toEqual([]);
expect(readSnapList(store, 'gibtsnicht')).toEqual([]);
});
it('parseSnaps wirft Einträge weg, die nicht die erwartete Form haben', () => {
const raw = JSON.stringify([{t: 1, text: 'gut'}, {t: 'später', text: 'x'}, {t: 2}, null, {text: 'ohne t'}]);
expect(parseSnaps(raw)).toEqual([{t: 1, text: 'gut'}]);
}); });
it.each([ it.each([
['leer', null], ['leer', null],
['leerer String', ''], ['leerer String', ''],
['kaputtes JSON', '{nicht json'], ['kaputtes JSON', '{nicht json'],
['kein Objekt', '"text"'], ['keine Liste', '"text"'],
['Array statt Objekt', '[1,2,3]'], ['Objekt statt Liste', '{"a":1}'],
['null', 'null'], ['null', 'null'],
])('gibt bei %s ein leeres Objekt zurück', (_name, raw) => { ])('gibt bei %s eine leere Liste zurück', (_name, raw) => {
expect(parseSnaps(raw)).toEqual({}); expect(parseSnaps(raw)).toEqual([]);
}); });
});
it('wirft Einträge weg, die nicht die erwartete Form haben', () => { describe('snapKey — der Schlüssel trägt das Präfix des Ablageschemas', () => {
const raw = JSON.stringify({ it('werkbaum-snaps:<id>', () => {
a: [{t: 1, text: 'gut'}, {t: 'späth', text: 'x'}, {t: 2}, null, {text: 'ohne t'}], expect(snapKey('example')).toBe(DOC_SNAPS_PREFIX + 'example');
b: 'keine Liste', expect(snapKey('live:https://x/api/v1/documents/a')).toBe(DOC_SNAPS_PREFIX + 'live:https://x/api/v1/documents/a');
c: []
});
expect(parseSnaps(raw)).toEqual({a: [{t: 1, text: 'gut'}]});
}); });
}); });
@@ -193,3 +228,4 @@ describe('snapLabel — heute die Uhrzeit, sonst mit Datum', () => {
expect(snapLabel(ts, 'xx-!', ts)).toBe('2026-03-05 14:37'); expect(snapLabel(ts, 'xx-!', ts)).toBe('2026-03-05 14:37');
}); });
}); });