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
|
## 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
|
- 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
|
- 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
|
- 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
|
Tabs im ersten genau den neuen visibilitychange-Flush auslöste, der die
|
||||||
Inszenierung überschrieb — der Flush bewies sich damit ungefragt selbst.
|
Inszenierung überschrieb — der Flush bewies sich damit ungefragt selbst.
|
||||||
519 Tests unverändert grün.
|
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
|
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):** Der Tastendruck schreibt über
|
**Persistenz ist geteilt (D82) und je Dokument abgelegt (D83):** Das
|
||||||
`persistActiveText()` NUR den Spiegel `werkbaum-src`; das volle Array
|
Schema liegt headless in `docstore.js` — `werkbaum-docs` ist nur der
|
||||||
schreibt `persistDocs()` an Flush-Punkten (Wechseln/Anlegen/Löschen/
|
INDEX `[{id,name,source?}]`, der Text jedes Dokuments ein eigener
|
||||||
Umbenennen, `pagehide`, verborgener Tab) — es serialisiert ALLE Dokumente,
|
Schlüssel `werkbaum-doc:<id>`. Der Tastendruck schreibt über
|
||||||
nie wieder in einen Tastendruck-Pfad hängen. Beim Laden **gewinnt der
|
`persistActiveText()`/`storeDocText()` NUR den aktiven Text (+ Spiegel
|
||||||
Spiegel** für das aktive Dokument (`loadDocs`), und zwar zwingend **vor**
|
`werkbaum-src` als Rollback-Fallback); den Voll-Flush macht
|
||||||
`seedShippedDocs()` — danach drehte der ältere Spiegel eine frisch
|
`persistDocs()`/`storeDocs()` an Flush-Punkten (Wechseln/Anlegen/Löschen/
|
||||||
nachgezogene Fassung zurück und das Dokument gälte für immer als
|
Umbenennen, `pagehide`, verborgener Tab) — mit Vergleich vor jedem
|
||||||
bearbeitet. Scheitert ein Write (Quota), meldet die persistente Warnung
|
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
|
`storeFailed` (`noteStore()`, rendert nur an der Flanke) — nie wieder
|
||||||
still schlucken.
|
still schlucken.
|
||||||
- Dokumente (D22): mehrere umschaltbare Notationstexte. `loadDocs()` migriert bei
|
- 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';
|
from './snapshots.js';
|
||||||
import { FILE_ACCEPT, FILE_TYPES, saveFileName } from './localfile.js';
|
import { FILE_ACCEPT, FILE_TYPES, saveFileName } from './localfile.js';
|
||||||
import { LIVE_PARAM, SOURCE_PARAM, ETHERPAD_PARAM, docSearch, docKind } from './docurl.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
|
/* Neuigkeiten (D58): die git-Historie, zur BAUZEIT eingelesen (Vite-Plugin in
|
||||||
vite.config.js). Zur Laufzeit gibt es kein git — und keinen Server, der
|
vite.config.js). Zur Laufzeit gibt es kein git — und keinen Server, der
|
||||||
nachliefern könnte (D11/D19). Leer, wo git nicht erreichbar war. */
|
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,
|
Noch kein Backend: Einstellungen (Modus, verworfene, Aufteilung, Zoom,
|
||||||
Vollbild) und der Editortext bleiben per localStorage über Neuladen
|
Vollbild) und der Editortext bleiben per localStorage über Neuladen
|
||||||
erhalten. Sprache liegt weiterhin in 'werkbaum-lang'. */
|
erhalten. Sprache liegt weiterhin in 'werkbaum-lang'. */
|
||||||
const LS_UI = 'werkbaum-ui', LS_SRC = 'werkbaum-src';
|
const LS_UI = 'werkbaum-ui';
|
||||||
const LS_DOCS = 'werkbaum-docs', LS_ACTIVE = 'werkbaum-active';
|
/* 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 = 'werkbaum-seeded'; /* mitgelieferte Dokumente schon angelegt? */
|
||||||
const LS_SEEDED_EXAMPLE = 'werkbaum-seeded-example'; /* Fingerabdruck der ausgelieferten INITIAL-Fassung (D27-Nachtrag) */
|
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 restoring = false; /* unterdrückt Speichern während des Wiederherstellens */
|
||||||
let hadStoredUI = false; /* gab es beim Laden schon gespeicherte GUI-Einstellungen? */
|
let hadStoredUI = false; /* gab es beim Laden schon gespeicherte GUI-Einstellungen? */
|
||||||
|
|
||||||
/* ---------- Dokumente (mehrere umschaltbare Notationstexte) ----------
|
/* ---------- Dokumente (mehrere umschaltbare Notationstexte) ----------
|
||||||
Noch kein Backend: mehrere Dokumente liegen als [{id,name,text}] im
|
Noch kein Backend: Der INDEX [{id,name,source?}] liegt unter LS_DOCS, der
|
||||||
localStorage (LS_DOCS), das aktive per id in LS_ACTIVE. Jedes Dokument ist
|
Text je Dokument unter einem eigenen Schlüssel (Schema: docstore.js, D83) —
|
||||||
nur ein Notationstext + Name (Metadatum) — kein eigenes Strukturformat (D14).
|
so trifft eine volle Quota nur das eine zu große Dokument und ein kaputter
|
||||||
Der aktive Text wird zusätzlich in LS_SRC gespiegelt (Abwärtskompatibilität
|
Schlüssel nur eines statt aller. Das aktive Dokument steht per id in
|
||||||
+ Migration bestehender Einzeltexte). */
|
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
|
/* 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
|
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
|
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++;
|
while(taken.has(base + ' ' + i)) i++;
|
||||||
return base + ' ' + i;
|
return base + ' ' + i;
|
||||||
}
|
}
|
||||||
/* Die volle Persistenz: serialisiert ALLE Dokumente — deshalb läuft sie nur
|
/* Die volle Persistenz (Flush-Punkte: Wechseln/Anlegen/Löschen/Umbenennen,
|
||||||
an Flush-Punkten (Wechseln/Anlegen/Löschen/Umbenennen, Verlassen der
|
Verlassen der Seite — nicht der Tastendruck, D82): Index + Texte über das
|
||||||
Seite), nicht mehr bei jedem Tastendruck (D82). Ein Fehlschlag (Quota
|
Ablageschema (docstore.js, D83). Unveränderte Schlüssel werden dort nicht
|
||||||
voll) wird gemeldet statt geschluckt. */
|
angefasst; ein Fehlschlag (Quota voll) wird gemeldet statt geschluckt. */
|
||||||
function persistDocs(){
|
function persistDocs(){
|
||||||
try{
|
try{
|
||||||
localStorage.setItem(LS_DOCS, JSON.stringify(docs));
|
storeDocs(localStorage, docs, Object.keys(localStorage));
|
||||||
localStorage.setItem(LS_ACTIVE, activeId || '');
|
localStorage.setItem(LS_ACTIVE, activeId || '');
|
||||||
const d = activeDoc();
|
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);
|
noteStore(true);
|
||||||
}catch(_){ noteStore(false); }
|
}catch(_){ noteStore(false); }
|
||||||
}
|
}
|
||||||
/* Die Tastendruck-Hälfte (D82): nur der Spiegel des AKTIVEN Texts (LS_SRC).
|
/* Die Tastendruck-Hälfte (D82/D83): der Text des AKTIVEN Dokuments unter
|
||||||
Für das aktive Dokument ist der Spiegel damit immer mindestens so neu wie
|
seinem eigenen Schlüssel plus der Spiegel — der Tastendruck schreibt damit
|
||||||
das Array — beim Laden gewinnt er (loadDocs). */
|
direkt in die echte Ablage, nicht mehr in eine Zwischenstation. */
|
||||||
function persistActiveText(){
|
function persistActiveText(){
|
||||||
try{
|
try{
|
||||||
const d = activeDoc();
|
const d = activeDoc();
|
||||||
if(d) localStorage.setItem(LS_SRC, d.text);
|
if(d) storeDocText(localStorage, d.id, d.text);
|
||||||
noteStore(true);
|
noteStore(true);
|
||||||
}catch(_){ noteStore(false); }
|
}catch(_){ noteStore(false); }
|
||||||
}
|
}
|
||||||
@@ -3465,18 +3469,19 @@ function seedShippedDocs(){
|
|||||||
if(shipped && shipped.text === WERKBAUM_DOC) computeFresh(WERKBAUM_ID, WERKBAUM_DOC);
|
if(shipped && shipped.text === WERKBAUM_DOC) computeFresh(WERKBAUM_ID, WERKBAUM_DOC);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Aus dem localStorage laden; bei fehlender Dokumentenliste den bestehenden
|
/* Aus dem localStorage laden (Schema: docstore.js, D83); bei fehlender oder
|
||||||
Einzeltext (oder INITIAL) als erstes Dokument migrieren. */
|
unbrauchbarer Dokumentenliste den bestehenden Einzeltext (oder INITIAL)
|
||||||
|
als erstes Dokument migrieren. */
|
||||||
function loadDocs(){
|
function loadDocs(){
|
||||||
let arr = null;
|
let gelesen = null;
|
||||||
try{ arr = JSON.parse(localStorage.getItem(LS_DOCS) || 'null'); }catch(_){}
|
try{ gelesen = readDocs(localStorage); }catch(_){}
|
||||||
if(!Array.isArray(arr) || !arr.length ||
|
if(gelesen){
|
||||||
!arr.every(d => d && typeof d.id === 'string' && typeof d.text === 'string')){
|
docs = gelesen.docs;
|
||||||
|
} else {
|
||||||
let legacy = null;
|
let legacy = null;
|
||||||
try{ legacy = localStorage.getItem(LS_SRC); }catch(_){}
|
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;
|
let a = null;
|
||||||
try{ a = localStorage.getItem(LS_ACTIVE); }catch(_){}
|
try{ a = localStorage.getItem(LS_ACTIVE); }catch(_){}
|
||||||
/* Alt-Zustand (erste Version: zufällige id, lokalisierter Name): ein noch
|
/* Alt-Zustand (erste Version: zufällige id, lokalisierter Name): ein noch
|
||||||
@@ -3489,19 +3494,22 @@ function loadDocs(){
|
|||||||
docs[0].name = EXAMPLE_NAME;
|
docs[0].name = EXAMPLE_NAME;
|
||||||
}
|
}
|
||||||
activeId = docs.some(d => d.id === a) ? a : docs[0].id;
|
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
|
Array erst an Flush-Punkten — für das aktive Dokument ist der Spiegel
|
||||||
also mindestens so neu. Wurde die Seite ohne Flush beendet (Absturz,
|
also mindestens so neu. Im neuen Schema schreibt der Tastendruck den
|
||||||
abgewürgter Tab), holt das hier die letzten Tastendrücke zurück.
|
Text-Schlüssel selbst; eine Vorrang-Regel bräuchte dort nur etwas, das
|
||||||
Zwingend VOR seedShippedDocs(): Danach kann das Array die frisch
|
es lügen lassen könnte. Zwingend VOR seedShippedDocs(): Danach kann das
|
||||||
nachgezogene Fassung tragen, und der (dann ältere) Spiegel würde sie
|
Array die frisch nachgezogene Fassung tragen, und der (dann ältere)
|
||||||
zurückdrehen — das Dokument gälte fortan als „bearbeitet" und bekäme
|
Spiegel würde sie zurückdrehen — das Dokument gälte fortan als
|
||||||
nie wieder eine neue Fassung. */
|
„bearbeitet" und bekäme nie wieder eine neue Fassung. */
|
||||||
|
if(gelesen && gelesen.legacy){
|
||||||
try{
|
try{
|
||||||
const spiegel = localStorage.getItem(LS_SRC);
|
const spiegel = localStorage.getItem(LS_SRC);
|
||||||
const d = docs.find(x => x.id === activeId);
|
const d = docs.find(x => x.id === activeId);
|
||||||
if(d && spiegel !== null && spiegel !== d.text) d.text = spiegel;
|
if(d && spiegel !== null && spiegel !== d.text) d.text = spiegel;
|
||||||
}catch(_){}
|
}catch(_){}
|
||||||
|
}
|
||||||
seedShippedDocs();
|
seedShippedDocs();
|
||||||
/* Namensfix für die kurzlebige Fassung mit dem Tippfehler — nur, solange der
|
/* Namensfix für die kurzlebige Fassung mit dem Tippfehler — nur, solange der
|
||||||
ausgelieferte Name unverändert ist; eine eigene Umbenennung bleibt stehen
|
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