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:
co-authored by
Claude Fable 5
parent
4f34454075
commit
3072f3a9c0
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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. */
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user