diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bf8047d..b89f9d5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,7 @@ reverse. ## 2026-08-27 +- Documents are stored one key each now, with a small index: a full storage no longer blocks every save, and one corrupted entry costs one document instead of all - Typing no longer serializes every document on each keystroke — only the active text is written, the full list on switching, closing or leaving the page - When the browser's storage is full, a warning says so instead of silently dropping changes until the next reload - Shipped documents are no longer renamable — their name is part of the delivered state, and a renamed but unedited example would still silently receive new versions diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 1b36007..a07ea50 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -7144,3 +7144,58 @@ Zwei-Tab-Versuch scheiterte lehrreich daran, dass das Fronten des zweiten Tabs im ersten genau den neuen visibilitychange-Flush auslöste, der die Inszenierung überschrieb — der Flush bewies sich damit ungefragt selbst. 519 Tests unverändert grün. + +## D83 — Dokument-Ablage aufgeteilt: Index + ein Schlüssel je Text +Nutzer-Einwand im Anschluss an D82: „Es ist tatsächlich so, dass beim +Zurückspeichern irgendwann sämtliche Dokumente neu geschrieben werden?" Ja — +D82 hatte nur die Frequenz repariert (nicht mehr je Tastendruck), nicht die +Struktur: Alle Dokumente lagen als EIN JSON unter einem Schlüssel, jeder +Flush schrieb alle Bytes aller Dokumente. Das eigentliche Problem daran ist +nicht die Performance (Flushes sind seltene Nutzeraktionen), sondern +**Alles-oder-nichts**: + +- **Quota:** Wächst das Gesamt-JSON über die Quota, scheitert jeder Flush + ganz — auch die Änderung an einem winzigen Dokument muss das Ganze + mitschreiben. +- **Korruption:** Ein einziger kaputter Schlüssel ließ `JSON.parse` + scheitern, und der Rückfallpfad ersetzte die Liste durch ein frisches + Beispiel — ein kaputter Schlüssel kostete die Sicht auf ALLE Dokumente. + Der teuerste Fehlermodus des ganzen Speicher-Designs. + +**Das Schema jetzt** (docstore.js, headless nach Hausregel D54-Nachtrag 3): +`werkbaum-docs` ist nur noch der **Index** `[{id, name, source?}]` — klein, +ändert sich selten; der Text jedes Dokuments liegt unter einem eigenen +Schlüssel `werkbaum-doc:`. Der Tastendruck schreibt damit **direkt in +die echte Ablage** (Text-Schlüssel + Spiegel) statt in eine Zwischenstation; +die „Spiegel gewinnt"-Regel aus D82 gilt nur noch der **einmaligen +Migration** aus dem Altformat — samt ihrer Reihenfolge-Falle (vor +`seedShippedDocs()`, sonst dreht der ältere Spiegel eine frisch nachgezogene +Fassung zurück). Der Voll-Flush vergleicht **vor jedem Schreiben** und fasst +unveränderte Schlüssel nicht an — sonst wäre die Write-Amplification nur +verteilt statt behoben — und räumt verwaiste Text-Schlüssel gelöschter +Dokumente ab, ohne fremde Schlüssel zu berühren. Ein fehlender oder kaputter +Text-Schlüssel ergibt ein Dokument mit leerem Text, **kein** Verwerfen der +Liste: Der Schaden bleibt, wo er entstand. + +**Bewusst NICHT IndexedDB:** Das wäre die Antwort auf wirklich viele große +Dokumente (asynchron, größere Quota), aber ein Umbau des ganzen synchronen +Ladepfads — und D22 hat den localStorage ohnehin als Platzhalter bis zum +Backend deklariert; wer so viele Dokumente hat, ist der Fall für +Server-Dokumente (D76). **Benannter Preis:** Ein Downgrade auf einen Build +vor D83 findet im Index keine Texte, hält die Liste für unbrauchbar und +fällt auf das Beispiel samt Spiegel (= aktiver Text) zurück — bei einer +Anwendung, die nur vorwärts deployt wird, tragbar. Die Stände (D54) behalten +ihren einen Schlüssel: Sie sind Sicherheitsnetz mit eigener +Verdrängungsregel, kein Bestand. + +**Nachgemessen** (529 Tests, +10 in `tests/docstore.test.js`, darunter +Rundreise, Nur-bei-Änderung-Schreiben per setItem-Zähler, Waisen-Abräumen, +Quota-Durchreichen): Im Browser migriert das Altformat beim ersten Laden +(Index ohne Texte, je Dokument ein Schlüssel); Tippen lässt den Index +unangetastet; ein gezielt zerstörter Text-Schlüssel kostet genau dieses eine +Dokument (leer im Menü, wiederherstellbar über den Neu-laden-Knopf), Liste +und Nachbar-Dokument bleiben unversehrt; Anlegen erzeugt den Schlüssel, +Löschen räumt ihn ab. Beim Prüfen fiel im Test-Profil das Werkbaum-Dokument +als fehlend auf — Ursache waren die inszenierten Speicherzustände der +D82-Prüfung selbst, nicht das neue Schema; der Seed-Merker tat das Richtige +(„gelöscht bleibt gelöscht", D27). diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 8045a2d..10f861d 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -149,15 +149,22 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der GUI-Einstellungen in `saveUI()`/`restoreState()` mitführen; `saveUI` liefert während `restoring===true` nichts, damit das Wiederherstellen nicht sofort zurückschreibt. - **Persistenz ist geteilt (D82):** Der Tastendruck schreibt über - `persistActiveText()` NUR den Spiegel `werkbaum-src`; das volle Array - schreibt `persistDocs()` an Flush-Punkten (Wechseln/Anlegen/Löschen/ - Umbenennen, `pagehide`, verborgener Tab) — es serialisiert ALLE Dokumente, - nie wieder in einen Tastendruck-Pfad hängen. Beim Laden **gewinnt der - Spiegel** für das aktive Dokument (`loadDocs`), und zwar zwingend **vor** - `seedShippedDocs()` — danach drehte der ältere Spiegel eine frisch - nachgezogene Fassung zurück und das Dokument gälte für immer als - bearbeitet. Scheitert ein Write (Quota), meldet die persistente Warnung + **Persistenz ist geteilt (D82) und je Dokument abgelegt (D83):** Das + Schema liegt headless in `docstore.js` — `werkbaum-docs` ist nur der + INDEX `[{id,name,source?}]`, der Text jedes Dokuments ein eigener + Schlüssel `werkbaum-doc:`. Der Tastendruck schreibt über + `persistActiveText()`/`storeDocText()` NUR den aktiven Text (+ Spiegel + `werkbaum-src` als Rollback-Fallback); den Voll-Flush macht + `persistDocs()`/`storeDocs()` an Flush-Punkten (Wechseln/Anlegen/Löschen/ + Umbenennen, `pagehide`, verborgener Tab) — mit Vergleich vor jedem + Schreiben und Abräumen verwaister Text-Schlüssel; nie wieder eine + Voll-Serialisierung in einen Tastendruck-Pfad hängen. Die „Spiegel + gewinnt"-Regel in `loadDocs` gilt NUR noch der einmaligen Migration aus + dem Altformat (Texte im Array) und 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. - Dokumente (D22): mehrere umschaltbare Notationstexte. `loadDocs()` migriert bei diff --git a/frontend/src/app.js b/frontend/src/app.js index b5c5e3e..6dc3fca 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -9,6 +9,7 @@ import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel from './snapshots.js'; import { FILE_ACCEPT, FILE_TYPES, saveFileName } from './localfile.js'; import { LIVE_PARAM, SOURCE_PARAM, ETHERPAD_PARAM, docSearch, docKind } from './docurl.js'; +import { readDocs, storeDocs, storeDocText, LS_DOCS, LS_ACTIVE, LS_SRC } from './docstore.js'; /* Neuigkeiten (D58): die git-Historie, zur BAUZEIT eingelesen (Vite-Plugin in vite.config.js). Zur Laufzeit gibt es kein git — und keinen Server, der nachliefern könnte (D11/D19). Leer, wo git nicht erreichbar war. */ @@ -3362,19 +3363,22 @@ fsBtn.addEventListener('click', () => { Noch kein Backend: Einstellungen (Modus, verworfene, Aufteilung, Zoom, Vollbild) und der Editortext bleiben per localStorage über Neuladen erhalten. Sprache liegt weiterhin in 'werkbaum-lang'. */ -const LS_UI = 'werkbaum-ui', LS_SRC = 'werkbaum-src'; -const LS_DOCS = 'werkbaum-docs', LS_ACTIVE = 'werkbaum-active'; +const LS_UI = 'werkbaum-ui'; +/* LS_DOCS / LS_ACTIVE / LS_SRC kommen aus docstore.js (D83) — dort liegt das + Ablageschema: Index ohne Texte, Text je Dokument unter eigenem Schlüssel. */ const LS_SEEDED = 'werkbaum-seeded'; /* mitgelieferte Dokumente schon angelegt? */ const LS_SEEDED_EXAMPLE = 'werkbaum-seeded-example'; /* Fingerabdruck der ausgelieferten INITIAL-Fassung (D27-Nachtrag) */ let restoring = false; /* unterdrückt Speichern während des Wiederherstellens */ let hadStoredUI = false; /* gab es beim Laden schon gespeicherte GUI-Einstellungen? */ /* ---------- Dokumente (mehrere umschaltbare Notationstexte) ---------- - Noch kein Backend: mehrere Dokumente liegen als [{id,name,text}] im - localStorage (LS_DOCS), das aktive per id in LS_ACTIVE. Jedes Dokument ist - nur ein Notationstext + Name (Metadatum) — kein eigenes Strukturformat (D14). - Der aktive Text wird zusätzlich in LS_SRC gespiegelt (Abwärtskompatibilität - + Migration bestehender Einzeltexte). */ + Noch kein Backend: Der INDEX [{id,name,source?}] liegt unter LS_DOCS, der + Text je Dokument unter einem eigenen Schlüssel (Schema: docstore.js, D83) — + so trifft eine volle Quota nur das eine zu große Dokument und ein kaputter + Schlüssel nur eines statt aller. Das aktive Dokument steht per id in + LS_ACTIVE; sein Text wird zusätzlich in LS_SRC gespiegelt (Rollback- und + Migrations-Fallback). Jedes Dokument ist nur ein Notationstext + Name + (Metadatum) — kein eigenes Strukturformat (D14). */ /* Das Beispiel-Dokument trägt eine reservierte id und einen festen englischen Namen: So trifft der Reset genau dieses eine Dokument (D22) und der Name ist unabhängig von der UI-Sprache englisch — wie der Beispieltext selbst (breiteres @@ -3396,26 +3400,26 @@ function uniqueName(base){ while(taken.has(base + ' ' + i)) i++; return base + ' ' + i; } -/* Die volle Persistenz: serialisiert ALLE Dokumente — deshalb läuft sie nur - an Flush-Punkten (Wechseln/Anlegen/Löschen/Umbenennen, Verlassen der - Seite), nicht mehr bei jedem Tastendruck (D82). Ein Fehlschlag (Quota - voll) wird gemeldet statt geschluckt. */ +/* Die volle Persistenz (Flush-Punkte: Wechseln/Anlegen/Löschen/Umbenennen, + Verlassen der Seite — nicht der Tastendruck, D82): Index + Texte über das + Ablageschema (docstore.js, D83). Unveränderte Schlüssel werden dort nicht + angefasst; ein Fehlschlag (Quota voll) wird gemeldet statt geschluckt. */ function persistDocs(){ try{ - localStorage.setItem(LS_DOCS, JSON.stringify(docs)); + storeDocs(localStorage, docs, Object.keys(localStorage)); localStorage.setItem(LS_ACTIVE, activeId || ''); const d = activeDoc(); - if(d) localStorage.setItem(LS_SRC, d.text); /* Spiegel, siehe persistActiveText */ + if(d) localStorage.setItem(LS_SRC, d.text); /* Spiegel: Rollback-Fallback */ noteStore(true); }catch(_){ noteStore(false); } } -/* Die Tastendruck-Hälfte (D82): nur der Spiegel des AKTIVEN Texts (LS_SRC). - Für das aktive Dokument ist der Spiegel damit immer mindestens so neu wie - das Array — beim Laden gewinnt er (loadDocs). */ +/* Die Tastendruck-Hälfte (D82/D83): der Text des AKTIVEN Dokuments unter + seinem eigenen Schlüssel plus der Spiegel — der Tastendruck schreibt damit + direkt in die echte Ablage, nicht mehr in eine Zwischenstation. */ function persistActiveText(){ try{ const d = activeDoc(); - if(d) localStorage.setItem(LS_SRC, d.text); + if(d) storeDocText(localStorage, d.id, d.text); noteStore(true); }catch(_){ noteStore(false); } } @@ -3465,18 +3469,19 @@ function seedShippedDocs(){ if(shipped && shipped.text === WERKBAUM_DOC) computeFresh(WERKBAUM_ID, WERKBAUM_DOC); } -/* Aus dem localStorage laden; bei fehlender Dokumentenliste den bestehenden - Einzeltext (oder INITIAL) als erstes Dokument migrieren. */ +/* Aus dem localStorage laden (Schema: docstore.js, D83); bei fehlender oder + unbrauchbarer Dokumentenliste den bestehenden Einzeltext (oder INITIAL) + als erstes Dokument migrieren. */ function loadDocs(){ - let arr = null; - try{ arr = JSON.parse(localStorage.getItem(LS_DOCS) || 'null'); }catch(_){} - if(!Array.isArray(arr) || !arr.length || - !arr.every(d => d && typeof d.id === 'string' && typeof d.text === 'string')){ + let gelesen = null; + try{ gelesen = readDocs(localStorage); }catch(_){} + if(gelesen){ + docs = gelesen.docs; + } else { let legacy = null; try{ legacy = localStorage.getItem(LS_SRC); }catch(_){} - arr = [{ id: EXAMPLE_ID, name: EXAMPLE_NAME, text: (legacy !== null) ? legacy : INITIAL }]; + docs = [{ id: EXAMPLE_ID, name: EXAMPLE_NAME, text: (legacy !== null) ? legacy : INITIAL }]; } - docs = arr; let a = null; try{ a = localStorage.getItem(LS_ACTIVE); }catch(_){} /* Alt-Zustand (erste Version: zufällige id, lokalisierter Name): ein noch @@ -3489,19 +3494,22 @@ function loadDocs(){ docs[0].name = EXAMPLE_NAME; } activeId = docs.some(d => d.id === a) ? a : docs[0].id; - /* Der Spiegel gewinnt (D82): Beim Tippen wird nur LS_SRC geschrieben, das + /* „Der Spiegel gewinnt" gilt nur noch der EINMALIGEN Migration aus dem + Altformat (D82→D83): Dort schrieb der Tastendruck nur den Spiegel, das Array erst an Flush-Punkten — für das aktive Dokument ist der Spiegel - also mindestens so neu. Wurde die Seite ohne Flush beendet (Absturz, - abgewürgter Tab), holt das hier die letzten Tastendrücke zurück. - Zwingend VOR seedShippedDocs(): Danach kann das Array die frisch - nachgezogene Fassung tragen, und der (dann ältere) Spiegel würde sie - zurückdrehen — das Dokument gälte fortan als „bearbeitet" und bekäme - nie wieder eine neue Fassung. */ - try{ - const spiegel = localStorage.getItem(LS_SRC); - const d = docs.find(x => x.id === activeId); - if(d && spiegel !== null && spiegel !== d.text) d.text = spiegel; - }catch(_){} + also mindestens so neu. Im neuen Schema schreibt der Tastendruck den + Text-Schlüssel selbst; eine Vorrang-Regel bräuchte dort nur etwas, das + es lügen lassen könnte. Zwingend VOR seedShippedDocs(): Danach kann das + Array die frisch nachgezogene Fassung tragen, und der (dann ältere) + Spiegel würde sie zurückdrehen — das Dokument gälte fortan als + „bearbeitet" und bekäme nie wieder eine neue Fassung. */ + if(gelesen && gelesen.legacy){ + try{ + const spiegel = localStorage.getItem(LS_SRC); + const d = docs.find(x => x.id === activeId); + if(d && spiegel !== null && spiegel !== d.text) d.text = spiegel; + }catch(_){} + } seedShippedDocs(); /* Namensfix für die kurzlebige Fassung mit dem Tippfehler — nur, solange der ausgelieferte Name unverändert ist; eine eigene Umbenennung bleibt stehen diff --git a/frontend/src/docstore.js b/frontend/src/docstore.js new file mode 100644 index 0000000..ede51b4 --- /dev/null +++ b/frontend/src/docstore.js @@ -0,0 +1,87 @@ +/* Werkbaum — Ablageschema der Dokumente im localStorage (D83, headless). + + Bis D82 lagen alle Dokumente als EIN JSON-Array unter einem Schlüssel — + mit zwei strukturellen Folgen: Bei voller Quota scheiterte jeder Flush + ganz (auch die Änderung an einem winzigen Dokument muss das Ganze + mitschreiben), und ein einziger kaputter Schlüssel kostete die Sicht auf + ALLE Dokumente. Deshalb die Aufteilung: + + - `werkbaum-docs` — der INDEX: [{id, name, source?}], ohne Text. + - `werkbaum-doc:` — der Text je Dokument, ein eigener Schlüssel. + - `werkbaum-src` — Spiegel des aktiven Texts (Rollback-Fallback: + ein älterer Build fällt auf ihn zurück). + + 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, + removeItem} hereingereicht, damit die Tests ihn stellen können. */ + +export const LS_DOCS = 'werkbaum-docs'; +export const LS_ACTIVE = 'werkbaum-active'; +export const LS_SRC = 'werkbaum-src'; +export const DOC_TEXT_PREFIX = 'werkbaum-doc:'; + +export function docTextKey(id){ return DOC_TEXT_PREFIX + id; } + +/* Dokumentenliste lesen. Rückgabe {docs, legacy} — `legacy` heißt: Das + Altformat lag (ganz oder teilweise) vor, der nächste Voll-Flush schließt + die Migration ab. `null`, wenn nichts Brauchbares da ist — dann greift der + Beispiel-/Migrations-Pfad des Aufrufers. + + Ein fehlender Text-Schlüssel ergibt einen LEEREN Text, kein Verwerfen des + Dokuments: Der Schaden bleibt auf das eine Dokument begrenzt — genau der + Punkt der Aufteilung. */ +export function readDocs(storage){ + let arr = null; + try{ arr = JSON.parse(storage.getItem(LS_DOCS) || 'null'); }catch(_){ return null; } + if(!Array.isArray(arr) || !arr.length) return null; + if(!arr.every(d => d && typeof d.id === 'string')) return null; + const legacy = arr.some(d => typeof d.text === 'string'); + const docs = arr.map(d => { + const out = { id: d.id, name: typeof d.name === 'string' ? d.name : d.id }; + if(typeof d.source === 'string') out.source = d.source; + if(typeof d.text === 'string'){ + out.text = d.text; /* Altformat: Text im Array */ + } else { + const t = storage.getItem(docTextKey(d.id)); + out.text = t == null ? '' : String(t); + } + return out; + }); + return {docs, legacy}; +} + +/* Voll-Flush: Index + Texte + verwaiste Text-Schlüssel entfernen. + + 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). */ +export function storeDocs(storage, docs, allKeys){ + const index = JSON.stringify(docs.map(d => { + const e = { id: d.id, name: d.name }; + if(d.source) e.source = d.source; + return e; + })); + 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); + } + const bleibt = new Set(docs.map(d => docTextKey(d.id))); + for(const k of (allKeys || [])){ + if(k.startsWith(DOC_TEXT_PREFIX) && !bleibt.has(k)) storage.removeItem(k); + } +} + +/* Die Tastendruck-Hälfte: nur der Text EINES Dokuments plus der Spiegel. + Anders als bis D82 schreibt der Tastendruck damit direkt in die echte + Ablage — eine „Spiegel gewinnt"-Regel beim Laden braucht das neue Schema + nur noch für die einmalige Migration aus dem Altformat. */ +export function storeDocText(storage, id, text){ + const k = docTextKey(id); + if(storage.getItem(k) !== text) storage.setItem(k, text); + if(storage.getItem(LS_SRC) !== text) storage.setItem(LS_SRC, text); +} diff --git a/frontend/tests/docstore.test.js b/frontend/tests/docstore.test.js new file mode 100644 index 0000000..806b237 --- /dev/null +++ b/frontend/tests/docstore.test.js @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { readDocs, storeDocs, storeDocText, docTextKey, + LS_DOCS, LS_SRC, DOC_TEXT_PREFIX } from '../src/docstore.js'; + +/* Storage-Attrappe mit Zähler — die Vergleich-vor-Schreiben-Regel ist nur + über die Zahl der setItem-Aufrufe zu beweisen. */ +function mem(init){ + const m = new Map(Object.entries(init || {})); + let writes = 0; + return { + getItem: k => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => { writes++; m.set(k, String(v)); }, + removeItem: k => { m.delete(k); }, + keys: () => [...m.keys()], + writes: () => writes, + }; +} + +const A = {id: 'example', name: 'Example', text: '- a'}; +const B = {id: 'k1', name: 'Sprint 15', text: '- b', source: 'https://x.example/p'}; + +describe('docstore — Index + Text je Dokument (D83)', () => { + it('Rundreise: storeDocs schreibt, readDocs liest dasselbe zurück', () => { + const s = mem(); + storeDocs(s, [A, B], s.keys()); + expect(readDocs(s)).toEqual({docs: [A, B], legacy: false}); + }); + + it('der Index trägt KEINEN Text — der liegt je Dokument unter eigenem Schlüssel', () => { + const s = mem(); + storeDocs(s, [A], s.keys()); + expect(s.getItem(LS_DOCS)).not.toContain('- a'); + expect(s.getItem(docTextKey('example'))).toBe('- a'); + }); + + it('Altformat (Texte im Array) wird gelesen und als legacy gemeldet', () => { + const s = mem({[LS_DOCS]: JSON.stringify([A, B])}); + const r = readDocs(s); + expect(r.legacy).toBe(true); + expect(r.docs).toEqual([A, B]); + }); + + it('ein fehlender Text-Schlüssel kostet nur diesen einen Text, nicht die Liste', () => { + const s = mem(); + storeDocs(s, [A, B], s.keys()); + s.removeItem(docTextKey('k1')); + const r = readDocs(s); + expect(r.docs.map(d => d.id)).toEqual(['example', 'k1']); + expect(r.docs[1].text).toBe(''); + expect(r.docs[0].text).toBe('- a'); + }); + + it('kaputter oder unbrauchbarer Index -> null (Beispiel-Pfad des Aufrufers)', () => { + expect(readDocs(mem({[LS_DOCS]: '{kaputt'}))).toBe(null); + expect(readDocs(mem({[LS_DOCS]: '"kein Array"'}))).toBe(null); + expect(readDocs(mem({[LS_DOCS]: '[]'}))).toBe(null); + expect(readDocs(mem({[LS_DOCS]: JSON.stringify([{name: 'ohne id'}])}))).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', () => { + const live = {id: 'live:https://w.example/api/v1/documents/abc', name: 'Plan', text: '- x'}; + const s = mem(); + storeDocs(s, [live], s.keys()); + expect(readDocs(s).docs).toEqual([live]); + 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(); + }); +});