feat(speicher): Dokument-Ablage aufgeteilt — Index + ein Schlüssel je Text (D83)

werkbaum-docs ist nur noch der Index [{id,name,source?}]; der Text jedes
Dokuments liegt unter werkbaum-doc:<id>. Damit trifft eine volle Quota nur
das eine zu große Dokument statt jeden Flush, und ein kaputter Schlüssel
kostet ein Dokument statt aller (vorher: JSON.parse-Fehler -> Rückfall auf
das Beispiel, alle Dokumente unsichtbar). Der Tastendruck schreibt direkt
den Text-Schlüssel; der Voll-Flush vergleicht vor jedem Schreiben und räumt
verwaiste Schlüssel ab. Altformat migriert beim ersten Laden; die
Spiegel-Regel aus D82 gilt nur noch dieser Migration. Schema headless in
docstore.js (+10 Tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-27 06:58:10 +02:00
co-authored by Claude Fable 5
parent 4f34454075
commit 3072f3a9c0
6 changed files with 307 additions and 46 deletions
+1
View File
@@ -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
+55
View File
@@ -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:<id>`. 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).
+16 -9
View File
@@ -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:<id>`. 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
+40 -32
View File
@@ -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 (D82D83): 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. */
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
+87
View File
@@ -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:<id>` 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);
}
+103
View File
@@ -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();
});
});