feat(editor): Mehr-Fenster-Betrieb gebaut — Ablageschema v3, Tombstones, docsync, Sperre je Dokument (RFC 002, D94)
Kein Fenster schreibt mehr den Schlüssel eines anderen: Text, Meta und frühere Stände liegen je Dokument unter eigenen Schlüsseln, der Index ist nur noch Reihenfolge-Hinweis und löscht nie, Löschungen hinterlassen einen Tombstone (7 Tage). Der Voll-Flush weicht dem Dirty-Flush. Damit sind Befund 2 und 3 des RFC weg — verschiedene Dokumente in zwei Fenstern kollidieren gar nicht mehr. docsync.js zieht Liste, Namen, Tombstones und den Stände-Cache aus dem storage-Ereignis nach; am aktiven Dokument die drei Fälle umbenannt / anderswo gelöscht (behalten, bis getippt wird) / fremd geschrieben. Der modale D89-Dialog samt Präsenz-Kanal ist ersatzlos raus; den einen Verlustfall — dasselbe nicht-`live:`-Dokument in beiden Fenstern vorn — findet eine Sperre per Web Locks, und das zweite Fenster bekommt drei Auswege statt „trotzdem fortfahren". Im Browser mit zwei echten Tabs nachgemessen (RFC §10): Fälle 1–8 grün, Fall 1 im Vor-v3-Build gegengeprüft und dort nachweislich rot. Fall 9 deckte auf, dass reviveGoneDoc() den Index-Hinweis nicht mitschrieb — ein durch Tippen wiederbelebtes Dokument hing bis zum nächsten Flush allein an seinen eigenen Schlüsseln und wäre bei einem Rückbau auf einen Build vor v3 still verlorengegangen; behoben, Gegenprobe per Mutation gezogen. Headless 666 Tests grün. Offen bleibt die Handarbeit: PWA neben Tab, Firefox, Safari, ein Browser ohne Locks-API. SPEC §9, D94, D89-Nachtrag, RFC (Status, §10, Revisionsgeschichte), CHANGELOG und Plan-Knoten #ed.docs.windows nachgezogen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
112ada4e58
commit
caaf7c15e1
+234
-69
@@ -1,119 +1,284 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readDocs, storeDocs, storeDocText, docTextKey, isDocKey,
|
||||
LS_DOCS, LS_SRC, DOC_TEXT_PREFIX } from '../src/docstore.js';
|
||||
import { readDocs, writeDoc, writeIndexHint, removeDoc, expireTombstones,
|
||||
migrateV3, storeDocText, readMeta, hasTombstone, docTextKey, docMetaKey,
|
||||
docGoneKey, isDocKey, GONE_TTL,
|
||||
LS_DOCS, LS_SRC, DOC_TEXT_PREFIX, DOC_META_PREFIX, DOC_GONE_PREFIX,
|
||||
DOC_SNAPS_PREFIX, LEGACY_SNAPS } from '../src/docstore.js';
|
||||
|
||||
/* Storage-Attrappe mit Zähler — die Vergleich-vor-Schreiben-Regel ist nur
|
||||
über die Zahl der setItem-Aufrufe zu beweisen. */
|
||||
/* Storage-Attrappe mit ZÄHLERN JE SCHLÜSSEL — die Hausregel des Schemas v3
|
||||
(„kein Fenster schreibt je den Schlüssel eines anderen", RFC 002 §6.1) ist
|
||||
nur über die Zahl der Zugriffe je Schlüssel zu beweisen. Fiele der alte
|
||||
Orphan-Sweep des D83-Voll-Flushes zurück, fiele GENAU dieser Test. */
|
||||
function mem(init){
|
||||
const m = new Map(Object.entries(init || {}));
|
||||
let writes = 0;
|
||||
const schreib = new Map(), loesch = new Map();
|
||||
return {
|
||||
getItem: k => (m.has(k) ? m.get(k) : null),
|
||||
setItem: (k, v) => { writes++; m.set(k, String(v)); },
|
||||
removeItem: k => { m.delete(k); },
|
||||
setItem: (k, v) => { schreib.set(k, (schreib.get(k) || 0) + 1); m.set(k, String(v)); },
|
||||
removeItem: k => { schreib.set(k, (schreib.get(k) || 0) - 1); m.delete(k); },
|
||||
keys: () => [...m.keys()],
|
||||
writes: () => writes,
|
||||
schreibFuer: k => schreib.get(k) || 0,
|
||||
schreibGesamt: () => [...schreib.values()].reduce((a, b) => a + Math.abs(b), 0),
|
||||
map: m,
|
||||
};
|
||||
}
|
||||
|
||||
const A = {id: 'example', name: 'Example', text: '- a'};
|
||||
const B = {id: 'k1', name: 'Sprint 15', text: '- b', source: 'https://x.example/p'};
|
||||
const JETZT = 1750000000000;
|
||||
|
||||
describe('docstore — Index + Text je Dokument (D83)', () => {
|
||||
it('Rundreise: storeDocs schreibt, readDocs liest dasselbe zurück', () => {
|
||||
function schreiben(s, doc, opts){ return writeDoc(s, doc, {now: JETZT, ...opts}); }
|
||||
function dokumente(s, keys){ return readDocs(s, keys ?? s.keys()); }
|
||||
|
||||
describe('docstore v3 — Union-Lesen (§6.1)', () => {
|
||||
it('Rundreise: writeDoc schreibt, readDocs liest dasselbe zurück', () => {
|
||||
const s = mem();
|
||||
storeDocs(s, [A, B], s.keys());
|
||||
expect(readDocs(s)).toEqual({docs: [A, B], legacy: false});
|
||||
schreiben(s, A);
|
||||
schreiben(s, B);
|
||||
expect(dokumente(s)).toEqual({docs: [A, B], legacy: false});
|
||||
});
|
||||
|
||||
it('der Index trägt KEINEN Text — der liegt je Dokument unter eigenem Schlüssel', () => {
|
||||
it('Meta trägt Name, Quelle und born — der Text liegt je Dokument unter eigenem Schlüssel', () => {
|
||||
const s = mem();
|
||||
storeDocs(s, [A], s.keys());
|
||||
expect(s.getItem(LS_DOCS)).not.toContain('- a');
|
||||
expect(s.getItem(docTextKey('example'))).toBe('- a');
|
||||
schreiben(s, B);
|
||||
expect(readMeta(s, B.id)).toEqual({name: B.name, source: B.source, born: JETZT});
|
||||
expect(s.getItem(LS_DOCS)).toBe(null);
|
||||
expect(s.getItem(docTextKey(B.id))).toBe('- b');
|
||||
});
|
||||
|
||||
it('Altformat (Texte im Array) wird gelesen und als legacy gemeldet', () => {
|
||||
it('Altformat (Texte im Index-Array) wird gelesen und als legacy gemeldet', () => {
|
||||
const s = mem({[LS_DOCS]: JSON.stringify([A, B])});
|
||||
const r = readDocs(s);
|
||||
const r = dokumente(s);
|
||||
expect(r.legacy).toBe(true);
|
||||
expect(r.docs).toEqual([A, B]);
|
||||
});
|
||||
|
||||
it('Union: ein Dokument mit Text-Schlüssel, aber ohne Meta und ohne Index, wird gefunden', () => {
|
||||
const s = mem();
|
||||
schreiben(s, A);
|
||||
s.setItem(DOC_TEXT_PREFIX + 'fremd', '- ein Nachzügler');
|
||||
const r = dokumente(s);
|
||||
expect(r.docs.map(d => d.id)).toEqual(['example', 'fremd']);
|
||||
expect(r.docs[1].name).toBe('fremd');
|
||||
});
|
||||
|
||||
it('Reihenfolge: Index zuerst, Nachzügler nach born', () => {
|
||||
const s = mem();
|
||||
schreiben(s, {...A, born: 100}, {now: 100});
|
||||
schreiben(s, B, {now: 50});
|
||||
writeIndexHint(s, [{id: 'k1', name: B.name}]);
|
||||
const r = dokumente(s);
|
||||
expect(r.docs.map(d => d.id)).toEqual(['k1', 'example']); /* Index ('k1') vor dem Nachzügler */
|
||||
});
|
||||
|
||||
it('Name aus Meta gewinnt vor dem Index-Hinweis', () => {
|
||||
const s = mem();
|
||||
schreiben(s, {...A, name: 'Neuer Name'});
|
||||
writeIndexHint(s, [{id: A.id, name: 'Alter Indexname'}]);
|
||||
expect(dokumente(s).docs[0].name).toBe('Neuer Name');
|
||||
});
|
||||
|
||||
it('ein Index-Eintrag ohne Text und ohne Meta ist ein Rest und wird ignoriert', () => {
|
||||
const s = mem({[LS_DOCS]: JSON.stringify([{id: 'ghost', name: 'Gespenst'}, {id: 'd1', name: 'Da'}])});
|
||||
s.setItem(docTextKey('d1'), '- x');
|
||||
expect(dokumente(s).docs.map(d => d.id)).toEqual(['d1']);
|
||||
});
|
||||
|
||||
it('ein fehlender Text-Schlüssel kostet nur diesen einen Text, nicht die Liste', () => {
|
||||
const s = mem();
|
||||
storeDocs(s, [A, B], s.keys());
|
||||
s.removeItem(docTextKey('k1'));
|
||||
const r = readDocs(s);
|
||||
schreiben(s, A);
|
||||
schreiben(s, B);
|
||||
s.removeItem(docTextKey(B.id));
|
||||
const r = dokumente(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('kaputter oder fehlender Index ist nur ein Hinweis — die eigenen Schlüssel zählen weiter', () => {
|
||||
const s = mem({[LS_DOCS]: '{kaputt'});
|
||||
schreiben(s, A);
|
||||
expect(dokumente(s).docs.map(d => d.id)).toEqual(['example']);
|
||||
expect(dokumente(mem())).toBe(null);
|
||||
});
|
||||
|
||||
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]);
|
||||
schreiben(s, live);
|
||||
expect(dokumente(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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDocKey — Fremd-Tab-Erkennung (D84)', () => {
|
||||
it('erkennt Index, aktives Dokument, Spiegel und Text-Schlüssel', () => {
|
||||
describe('docstore v3 — Tombstones (§6.2)', () => {
|
||||
it('gelöscht bleibt gelöscht: der Tombstone nimmt die id aus dem Lesen', () => {
|
||||
const s = mem();
|
||||
schreiben(s, A);
|
||||
removeDoc(s, A.id, JETZT);
|
||||
expect(hasTombstone(s, A.id)).toBe(true);
|
||||
expect(s.getItem(docTextKey(A.id))).toBe(null);
|
||||
expect(s.getItem(docMetaKey(A.id))).toBe(null);
|
||||
expect(s.getItem(DOC_SNAPS_PREFIX + A.id)).toBe(null);
|
||||
expect(dokumente(s)).toBe(null);
|
||||
});
|
||||
|
||||
it('ein Tombstone blockiert das stille Wiederanlegen — Tippen hebt ihn auf', () => {
|
||||
const s = mem();
|
||||
schreiben(s, A);
|
||||
removeDoc(s, A.id, JETZT);
|
||||
expect(writeDoc(s, {...A, text: '- neu'}, {now: JETZT})).toBe(false); /* Flush schreibt nicht */
|
||||
expect(hasTombstone(s, A.id)).toBe(true);
|
||||
expect(writeDoc(s, {...A, text: '- neu'}, {now: JETZT, typed: true})).toBe(true); /* Tippen ist Absicht */
|
||||
expect(hasTombstone(s, A.id)).toBe(false);
|
||||
expect(s.getItem(docTextKey(A.id))).toBe('- neu');
|
||||
});
|
||||
|
||||
it('storeDocText (der Tastendruck) hebt den Tombstone selbst', () => {
|
||||
const s = mem();
|
||||
removeDoc(s, A.id, JETZT);
|
||||
storeDocText(s, A.id, '- getippt');
|
||||
expect(hasTombstone(s, A.id)).toBe(false);
|
||||
expect(s.getItem(docTextKey(A.id))).toBe('- getippt');
|
||||
});
|
||||
|
||||
it('Tombstones verfallen nach 7 Tagen — junge bleiben, alte und defekte fallen', () => {
|
||||
const s = mem();
|
||||
s.setItem(docGoneKey('alt'), String(JETZT - GONE_TTL - 1));
|
||||
s.setItem(docGoneKey('jung'), String(JETZT - GONE_TTL + 1));
|
||||
s.setItem(docGoneKey('kaputt'), 'gar kein Datum');
|
||||
expireTombstones(s, s.keys(), JETZT);
|
||||
expect(hasTombstone(s, 'alt')).toBe(false);
|
||||
expect(hasTombstone(s, 'jung')).toBe(true);
|
||||
expect(hasTombstone(s, 'kaputt')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('docstore v3 — die Flush-Grenze: fremde Schlüssel werden nie angefasst', () => {
|
||||
/* DIE Gegenprobe des RFC (§10, Mutation): Baut jemand den Orphan-Sweep des
|
||||
D83-Voll-Flushes zurück, fällt genau dieser Test — der Schaden von damals
|
||||
(Befund 2: B's Dokument samt Text weg) ist hier als Zähler beweisbar. */
|
||||
it('persistDocs (writeDoc + writeIndexHint) schreibt nur EIGENE Schlüssel, entfernt KEINEN', () => {
|
||||
const s = mem();
|
||||
schreiben(s, A);
|
||||
/* Der Zustand, der Befund 2 war: das andere Fenster hat ein Dokument
|
||||
angelegt, das diese Liste nicht kennt. */
|
||||
const fremd = {id: 'fremdes-plan', name: 'Plan aus Fenster B', text: '- b'};
|
||||
schreiben(s, fremd);
|
||||
/* Der Flush dieses Fensters kennt nur A: Dirty-Flush + Index-Hinweis. */
|
||||
writeDoc(s, A);
|
||||
writeIndexHint(s, [{id: A.id, name: A.name}]);
|
||||
expect(s.getItem(docTextKey('fremdes-plan'))).toBe('- b'); /* unberührt … */
|
||||
expect(s.getItem(docMetaKey('fremdes-plan'))).not.toBe(null); /* … und nicht entfernt */
|
||||
/* und nichts von B ist verloren: */
|
||||
expect(dokumente(s).docs.map(d => d.id).sort()).toEqual(['example', 'fremdes-plan']);
|
||||
});
|
||||
|
||||
it('writeDoc schreibt nur Meta und Text des einen Dokuments — Zähler je Schlüssel', () => {
|
||||
const s = mem();
|
||||
schreiben(s, A);
|
||||
const zugriffe = k => s.schreibFuer(k);
|
||||
const vorA = zugriffe('werkbaum-meta:example') + zugriffe('werkbaum-doc:example');
|
||||
schreiben(s, A); /* dieselben Inhalte — Vergleich greift */
|
||||
expect(zugriffe('werkbaum-meta:example') + zugriffe('werkbaum-doc:example')).toBe(vorA);
|
||||
expect(zugriffe(DOC_META_PREFIX + 'k1')).toBe(0);
|
||||
expect(zugriffe(DOC_TEXT_PREFIX + 'k1')).toBe(0);
|
||||
});
|
||||
|
||||
it('born bleibt erhalten: Umbenennen dreht die Ablage-Reihenfolge nicht um', () => {
|
||||
const s = mem();
|
||||
schreiben(s, {...A, born: 5}, {now: JETZT});
|
||||
writeDoc(s, {...A, name: 'Umbenannt'}, {now: JETZT + 1000}); /* ohne born im Objekt */
|
||||
expect(readMeta(s, A.id).born).toBe(5);
|
||||
});
|
||||
|
||||
it('writeIndexHint schreibt den Hinweis — und löscht nichts', () => {
|
||||
const s = mem({[LS_DOCS]: JSON.stringify([{id: 'a', name: 'x'}])});
|
||||
writeIndexHint(s, []);
|
||||
expect(s.getItem(LS_DOCS)).toBe('[]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('docstore v3 — Migration (§6.1, idempotent)', () => {
|
||||
it('teilt den alten Stände-Sammel-Schlüssel auf und entfernt ihn', () => {
|
||||
const s = mem({
|
||||
[LEGACY_SNAPS]: JSON.stringify({a: [{t: 1, text: 'x'}], b: [{t: 2, text: 'y'}, {t: 'kaputt'}]}),
|
||||
[DOC_TEXT_PREFIX + 'a']: '- a',
|
||||
});
|
||||
migrateV3(s, JETZT);
|
||||
expect(s.getItem(LEGACY_SNAPS)).toBe(null);
|
||||
expect(s.getItem(DOC_SNAPS_PREFIX + 'a')).toBe(JSON.stringify([{t: 1, text: 'x'}]));
|
||||
/* b's Liste bleibt — nur die defekten Einträge fallen. */
|
||||
expect(s.getItem(DOC_SNAPS_PREFIX + 'b')).toBe(JSON.stringify([{t: 2, text: 'y'}]));
|
||||
/* Ohne Index gibt es keine Meta-Quelle — 'a' bleibt ein Nachzügler. */
|
||||
expect(readMeta(s, 'a')).toBe(null);
|
||||
});
|
||||
|
||||
it('zweimal laufen = einmal laufen (gleicher Inhalt, keine zusätzlichen Schreibvorgänge)', () => {
|
||||
const s = mem({
|
||||
[LEGACY_SNAPS]: JSON.stringify({a: [{t: 1, text: 'x'}]}),
|
||||
[LS_DOCS]: JSON.stringify([{id: 'a', name: 'A'}]),
|
||||
});
|
||||
migrateV3(s, JETZT);
|
||||
const snapshot = new Map(s.map);
|
||||
const n = s.schreibGesamt();
|
||||
migrateV3(s, JETZT + 1000); /* zweimal: schreibt nichts mehr */
|
||||
expect(s.schreibGesamt()).toBe(n);
|
||||
expect([...s.map.entries()]).toEqual([...snapshot.entries()]);
|
||||
});
|
||||
|
||||
it('schreibt Meta nur für Dokumente MIT Text — Reste ohne Text bleiben ohne Meta', () => {
|
||||
const s = mem({[LS_DOCS]: JSON.stringify([{id: 'da', name: 'Da', text: '- x'}, {id: 'rest', name: 'Rest'}])});
|
||||
migrateV3(s, JETZT);
|
||||
expect(readMeta(s, 'da')).toEqual({name: 'Da', born: JETZT});
|
||||
expect(s.getItem(DOC_TEXT_PREFIX + 'da')).toBe('- x');
|
||||
expect(readMeta(s, 'rest')).toBe(null);
|
||||
expect(s.getItem(DOC_TEXT_PREFIX + 'rest')).toBe(null);
|
||||
});
|
||||
|
||||
it('bestehende Meta bleibt stehen (born kommt nicht unter die Migration)', () => {
|
||||
const s = mem({
|
||||
[LS_DOCS]: JSON.stringify([{id: 'a', name: 'Alt'}]),
|
||||
[docMetaKey('a')]: JSON.stringify({name: 'Eigenname', born: 1}),
|
||||
[DOC_TEXT_PREFIX + 'a']: '- x',
|
||||
});
|
||||
migrateV3(s, JETZT);
|
||||
expect(readMeta(s, 'a')).toEqual({name: 'Eigenname', born: 1});
|
||||
});
|
||||
});
|
||||
|
||||
describe('storeDocText — Tastendruck-Hälfte (unverändert, plus Tombstone)', () => {
|
||||
it('schreibt Text-Schlüssel UND Spiegel — und nur bei Änderung', () => {
|
||||
const s = mem();
|
||||
storeDocText(s, 'example', '- neu');
|
||||
expect(s.getItem(docTextKey('example'))).toBe('- neu');
|
||||
expect(s.getItem(LS_SRC)).toBe('- neu');
|
||||
const vorher = s.schreibGesamt();
|
||||
storeDocText(s, 'example', '- neu');
|
||||
expect(s.schreibGesamt()).toBe(vorher);
|
||||
});
|
||||
|
||||
it('Quota-Fehler laufen zum Aufrufer', () => {
|
||||
const voll = {getItem: () => null, setItem: () => { throw new Error('QuotaExceeded'); }, removeItem: () => {}};
|
||||
expect(() => writeDoc(voll, A)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDocKey — die Schlüssel der Synchronisation (D84, RFC 002 §6.3)', () => {
|
||||
it('erkennt Index, aktives Dokument, Spiegel, alte Stände und alle per-Dokument-Schlüssel', () => {
|
||||
expect(isDocKey(LS_DOCS)).toBe(true);
|
||||
expect(isDocKey('werkbaum-active')).toBe(true);
|
||||
expect(isDocKey(LS_SRC)).toBe(true);
|
||||
expect(isDocKey(LEGACY_SNAPS)).toBe(true);
|
||||
expect(isDocKey(DOC_TEXT_PREFIX + 'live:https://x/api/v1/documents/a')).toBe(true);
|
||||
expect(isDocKey(DOC_META_PREFIX + 'example')).toBe(true);
|
||||
expect(isDocKey(DOC_GONE_PREFIX + 'example')).toBe(true);
|
||||
expect(isDocKey(DOC_SNAPS_PREFIX + 'example')).toBe(true);
|
||||
});
|
||||
|
||||
it('fremde Schlüssel und Ansichts-Zustand sind keine Dokument-Schlüssel', () => {
|
||||
it('fremde Schlüssel und Ansicht-Zustand sind keine Dokument-Schlüssel', () => {
|
||||
expect(isDocKey('werkbaum-ui')).toBe(false);
|
||||
expect(isDocKey('werkbaum-lang')).toBe(false);
|
||||
expect(isDocKey('werkbaum-snaps')).toBe(false);
|
||||
expect(isDocKey('werkbaum-seeded')).toBe(false);
|
||||
expect(isDocKey(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { applyStorageEvent, lockDecision, lockName, tombstoneExpired,
|
||||
createDirtySet, GONE_TTL } from '../src/docsync.js';
|
||||
|
||||
/* docsync (RFC 002 §6.3): Die Ereignis-Matrix — was ein storage-Ereignis im
|
||||
laufenden Fenster bedeutet. Headless: Liste und Ereignis sind Daten.
|
||||
|
||||
Fixture: A ist dieses Fenster. Das Ereignis kommt von Fenster B. */
|
||||
function apply(docs, activeId, key, newValue, oldValue){
|
||||
return applyStorageEvent(docs, activeId, {key, newValue, oldValue});
|
||||
}
|
||||
const D = (id, name, text) => ({id, name, text: text ?? ''});
|
||||
|
||||
describe('applyStorageEvent — die Matrix aus §6.3', () => {
|
||||
it('meta neu: Dokument hängt in der Liste, ohne Text (lazy)', () => {
|
||||
const docs = [D('a', 'A', '- a')];
|
||||
const erg = apply(docs, 'a', 'werkbaum-meta:neu', '{"name":"Neu","born":1}');
|
||||
expect(erg.docs).toEqual([{id: 'a', name: 'A', text: '- a'}, {id: 'neu', name: 'Neu', text: ''}]);
|
||||
expect(erg.action).toBe('created');
|
||||
});
|
||||
|
||||
it('meta geändert am FREMDEN Dokument: Name/Quelle übernehmen, kein action', () => {
|
||||
const docs = [D('a', 'A', '- a'), D('b', 'Alt')];
|
||||
const erg = apply(docs, 'a', 'werkbaum-meta:b', '{"name":"Neu","born":1}');
|
||||
expect(erg.docs[0]).toBe(docs[0]); /* dasselbe Array — die Mutation trägt */
|
||||
expect(erg.docs[1].name).toBe('Neu');
|
||||
expect(erg.action).toBe(null);
|
||||
});
|
||||
|
||||
it('meta geändert am AKTIVEN Dokument: action „renamed" — der Chip folgt', () => {
|
||||
const docs = [D('a', 'Alt')];
|
||||
const erg = apply(docs, 'a', 'werkbaum-meta:a', '{"name":"Neu","born":1}');
|
||||
expect(erg.action).toBe('renamed');
|
||||
expect(erg.docs[0].name).toBe('Neu');
|
||||
});
|
||||
|
||||
it('gone neu, id NICHT aktiv: aus der Liste', () => {
|
||||
const docs = [D('a', 'A', '- a'), D('b', 'B', '- b')];
|
||||
const erg = apply(docs, 'a', 'werkbaum-gone:b', '123');
|
||||
expect(erg.docs.map(d => d.id)).toEqual(['a']);
|
||||
expect(erg.action).toBe(null);
|
||||
});
|
||||
|
||||
it('gone neu, id AKTIV: behalten, action „deleted" — warnfarben, bis getippt wird (§6.5)', () => {
|
||||
const docs = [D('a', 'A', '- a')];
|
||||
const erg = apply(docs, 'a', 'werkbaum-gone:a', '123');
|
||||
expect(erg.docs).toBe(docs); /* behalten */
|
||||
expect(erg.action).toBe('deleted');
|
||||
});
|
||||
|
||||
it('gone aufgehoben (newValue null): nichts tun — das meta-Ereignis legt an', () => {
|
||||
const erg = apply([D('a', 'A')], 'a', 'werkbaum-gone:a', null);
|
||||
expect(erg.action).toBe(null);
|
||||
});
|
||||
|
||||
it('doc geändert, id NICHT aktiv: Text für die Vorschau nachziehen', () => {
|
||||
const docs = [D('a', 'A', '- a'), D('b', 'B', '- alt')];
|
||||
const erg = apply(docs, 'a', 'werkbaum-doc:b', '- neu');
|
||||
expect(erg.docs[1].text).toBe('- neu');
|
||||
expect(erg.action).toBe(null);
|
||||
});
|
||||
|
||||
it('doc geändert, id AKTIV, kein live: — NICHTS am Text, action „foreignWrite" (§6.6)', () => {
|
||||
const docs = [D('a', 'A', '- mein Text')];
|
||||
const erg = apply(docs, 'a', 'werkbaum-doc:a', '- fremder Text');
|
||||
expect(erg.docs[0].text).toBe('- mein Text'); /* das Getippte bleibt */
|
||||
expect(erg.action).toBe('foreignWrite');
|
||||
});
|
||||
|
||||
it('doc geändert, id aktiv, live: — nichts, der Feed ist die Quelle', () => {
|
||||
const docs = [D('live:https://x/d1', 'Geteilt', '- gemeinsam')];
|
||||
const erg = apply(docs, 'live:https://x/d1', 'werkbaum-doc:live:https://x/d1', '- server');
|
||||
expect(erg.docs[0].text).toBe('- gemeinsam');
|
||||
expect(erg.action).toBe(null);
|
||||
});
|
||||
|
||||
it('doc geändert, id unbekannt: ignorieren — das meta-Ereignis legt das Dokument an', () => {
|
||||
const docs = [D('a', 'A')];
|
||||
const erg = apply(docs, 'a', 'werkbaum-doc:fremd', '- x');
|
||||
expect(erg.docs).toBe(docs);
|
||||
expect(erg.action).toBe(null);
|
||||
});
|
||||
|
||||
it('snaps: action „snaps" — der Stände-Cache der id wird verworfen', () => {
|
||||
const erg = apply([D('a', 'A')], 'a', 'werkbaum-snaps:b', '[{"t":1,"text":"x"}]');
|
||||
expect(erg.action).toBe('snaps');
|
||||
expect(erg.id).toBe('b');
|
||||
});
|
||||
|
||||
it('docs (Index-Hinweis): Reihenfolge übernehmen, unbekannte ids bleiben hinten', () => {
|
||||
const docs = [D('a', 'A'), D('b', 'B'), D('fremd', 'F')];
|
||||
const erg = apply(docs, 'a', 'werkbaum-docs', '[{"id":"b","name":"B"},{"id":"a","name":"A"}]');
|
||||
expect(erg.docs.map(d => d.id)).toEqual(['b', 'a', 'fremd']);
|
||||
expect(erg.action).toBe('order');
|
||||
});
|
||||
|
||||
it('docs unlesbar oder bereits gleich: nichts', () => {
|
||||
const docs = [D('a', 'A')];
|
||||
expect(apply(docs, 'a', 'werkbaum-docs', '{kaputt').docs).toBe(docs);
|
||||
expect(apply(docs, 'a', 'werkbaum-docs', null).docs).toBe(docs);
|
||||
});
|
||||
|
||||
it('Harmlose Schlüssel (active, Spiegel, Ansicht, Merker): nichts', () => {
|
||||
const docs = [D('a', 'A')];
|
||||
for(const k of ['werkbaum-active', 'werkbaum-src', 'werkbaum-ui', 'werkbaum-lang', 'werkbaum-seeded']){
|
||||
const erg = apply(docs, 'a', k, '1');
|
||||
expect(erg.docs).toBe(docs);
|
||||
expect(erg.action).toBe(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('lockDecision — gesperrt wird nie ein geteiltes Dokument (§6.4)', () => {
|
||||
it('nicht „live:" wird gesperrt — eigene, URL- und Datei-Dokumente ebenso', () => {
|
||||
expect(lockDecision('example')).toBe(true);
|
||||
expect(lockDecision('d12345')).toBe(true);
|
||||
expect(lockDecision('url:https://x.example/p.werkbaum')).toBe(true);
|
||||
});
|
||||
|
||||
it('live: nie — zwei Fenster sind zwei Live-Clients, der Server führt zusammen', () => {
|
||||
expect(lockDecision('live:https://w.example/api/v1/documents/abc')).toBe(false);
|
||||
expect(lockDecision(null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tombstoneExpired — Verfall nach 7 Tagen (§6.2)', () => {
|
||||
const jetzt = 1750000000000;
|
||||
it('junge Tombstones bleiben', () => {
|
||||
expect(tombstoneExpired(jetzt - GONE_TTL + 1, jetzt)).toBe(false);
|
||||
expect(tombstoneExpired(jetzt, jetzt)).toBe(false);
|
||||
});
|
||||
|
||||
it('nach sieben Tagen fallen sie', () => {
|
||||
expect(tombstoneExpired(jetzt - GONE_TTL - 1, jetzt)).toBe(true);
|
||||
});
|
||||
|
||||
it('ohne brauchbaren Zeitstempel gilt der Tombstone als verfallen', () => {
|
||||
expect(tombstoneExpired(undefined, jetzt)).toBe(true);
|
||||
expect(tombstoneExpired('kaputt', jetzt)).toBe(true);
|
||||
expect(tombstoneExpired(0, jetzt)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDirtySet — die Flush-Punkte schreiben nur Eigenes (§6.1)', () => {
|
||||
it('sammelt ids, take räumt auf', () => {
|
||||
const dirty = createDirtySet();
|
||||
expect(dirty.entries()).toEqual([]);
|
||||
dirty.add('a'); dirty.add('b'); dirty.add('a');
|
||||
expect(dirty.entries()).toEqual(['a', 'b']);
|
||||
dirty.clear();
|
||||
expect(dirty.entries()).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignoriert fehlende ids', () => {
|
||||
const dirty = createDirtySet();
|
||||
dirty.add(null); dirty.add(undefined); dirty.add('');
|
||||
expect(dirty.entries()).toEqual([]);
|
||||
dirty.add('x');
|
||||
expect(dirty.has('x')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LS_SNAPS, SNAP_KEEP, parseSnaps, addSnapshot, dropOldestSnap,
|
||||
persistSnaps, snapLabel } from '../src/snapshots.js';
|
||||
import { SNAP_EVERY, SNAP_KEEP, parseSnaps, addSnapshot, dropOldestSnap,
|
||||
persistSnaps, readSnapList, readAllSnaps, snapLabel, snapKey } from '../src/snapshots.js';
|
||||
import { DOC_SNAPS_PREFIX } from '../src/docstore.js';
|
||||
|
||||
/* Frühere Stände (D54). Herausgezogen aus app.js, damit genau das prüfbar
|
||||
wird, was dort dreimal daneben lag: die Regel, wann ein Stand entsteht. */
|
||||
wird, was dort dreimal daneben lag: die Regel, wann ein Stand entsteht.
|
||||
Seit RFC 002 liegt JEDES Dokument unter eigenem Schlüssel — der alte
|
||||
Sammel-Schlüssel ließ den Flush des einen Fensters die Stände des anderen
|
||||
wegwerfen, die Rettungs-Sicherungen eingeschlossen (Befund 3). */
|
||||
|
||||
describe('addSnapshot — Takt und Knopf unterscheiden sich in einer Sache', () => {
|
||||
/* DER gemeldete Fehler (D54-Nachtrag 2): Dokument geöffnet, nichts geändert,
|
||||
@@ -71,10 +75,10 @@ describe('addSnapshot — es bleiben die letzten 20', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropOldestSnap — das Älteste geht zuerst, dokumentübergreifend', () => {
|
||||
it('trifft das älteste über alle Dokumente hinweg', () => {
|
||||
describe('dropOldestSnap — das Älteste geht zuerst, dokumentübergreifend (im Gedächtnis)', () => {
|
||||
it('trifft das älteste über alle Dokumente hinweg und sagt, welches es war', () => {
|
||||
const snaps = {a: [{t: 50, text: 'a1'}], b: [{t: 10, text: 'b1'}, {t: 60, text: 'b2'}]};
|
||||
expect(dropOldestSnap(snaps)).toBe(true);
|
||||
expect(dropOldestSnap(snaps)).toBe('b');
|
||||
expect(snaps.b.map(s => s.text)).toEqual(['b2']);
|
||||
expect(snaps.a).toHaveLength(1);
|
||||
});
|
||||
@@ -85,86 +89,117 @@ describe('dropOldestSnap — das Älteste geht zuerst, dokumentübergreifend', (
|
||||
expect(snaps).toEqual({});
|
||||
});
|
||||
|
||||
it('meldet false, wenn nichts mehr da ist', () => {
|
||||
expect(dropOldestSnap({})).toBe(false);
|
||||
expect(dropOldestSnap({a: []})).toBe(false);
|
||||
it('meldet null, wenn nichts mehr da ist', () => {
|
||||
expect(dropOldestSnap({})).toBe(null);
|
||||
expect(dropOldestSnap({a: []})).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
/* Der Speicher ist mit den Dokumenten geteilt. Läuft er über, sollen die
|
||||
Dokumente überleben — Stände sind das Nachgeben-Bare. */
|
||||
describe('persistSnaps — Dokumente gehen vor Ständen', () => {
|
||||
describe('persistSnaps — je Dokument geschrieben, Verdrängung über die Schlüssel', () => {
|
||||
function speicher(limit){
|
||||
const s = {daten: null, entfernt: false, versuche: 0};
|
||||
return Object.assign(s, {
|
||||
const m = new Map();
|
||||
return {
|
||||
getItem: k => (m.has(k) ? m.get(k) : null),
|
||||
setItem(k, v){
|
||||
s.versuche++;
|
||||
if(v.length > limit) throw new Error('QuotaExceededError');
|
||||
s.daten = v;
|
||||
if(String(v).length > limit) throw new Error('QuotaExceededError');
|
||||
m.set(k, String(v));
|
||||
},
|
||||
removeItem(){ s.entfernt = true; }
|
||||
});
|
||||
removeItem: k => m.delete(k),
|
||||
keys: () => [...m.keys()],
|
||||
map: m,
|
||||
};
|
||||
}
|
||||
|
||||
it('speichert, wenn es passt', () => {
|
||||
it('speichert unter dem Schlüssel des Dokuments, wenn es passt', () => {
|
||||
const store = speicher(1e6), snaps = {a: [{t: 1, text: 'kurz'}]};
|
||||
expect(persistSnaps(snaps, store)).toBe(true);
|
||||
expect(JSON.parse(store.daten)).toEqual(snaps);
|
||||
expect(store.versuche).toBe(1);
|
||||
expect(persistSnaps(store, snaps, 'a', store.keys())).toBe(true);
|
||||
expect(JSON.parse(store.getItem(snapKey('a')))).toEqual(snaps.a);
|
||||
});
|
||||
|
||||
it('wirft die ältesten Stände weg, bis es passt', () => {
|
||||
const snaps = {a: [{t: 1, text: 'x'.repeat(200)}, {t: 3, text: 'y'.repeat(200)}],
|
||||
b: [{t: 2, text: 'z'.repeat(200)}]};
|
||||
const store = speicher(300);
|
||||
expect(persistSnaps(snaps, store)).toBe(true);
|
||||
/* Übrig bleibt der jüngste — t:1 und t:2 fielen in dieser Reihenfolge. */
|
||||
expect(Object.keys(snaps)).toEqual(['a']);
|
||||
it('wirft den ältesten Stand des EIGENEN Dokuments weg, bis der eigene Schlüssel passt', () => {
|
||||
const snaps = {a: [{t: 1, text: 'x'.repeat(200)}, {t: 3, text: 'y'.repeat(200)}]};
|
||||
const store = speicher(220); /* eine Liste (217 Zeichen) passt, beide nicht */
|
||||
expect(persistSnaps(store, snaps, 'a', store.keys())).toBe(true);
|
||||
expect(snaps.a.map(s => s.t)).toEqual([3]);
|
||||
});
|
||||
|
||||
it('gibt auf und räumt den Schlüssel weg, wenn selbst leer nicht passt', () => {
|
||||
const snaps = {a: [{t: 1, text: 'x'}]};
|
||||
const store = speicher(1); /* nicht einmal "{}" passt */
|
||||
expect(persistSnaps(snaps, store)).toBe(false);
|
||||
expect(store.entfernt).toBe(true);
|
||||
expect(snaps).toEqual({});
|
||||
it('greift im Notfall auf den ältesten Stand EINES FREMDEN Dokuments zurück — lesend, §6.8', () => {
|
||||
const snaps = {a: [{t: 9, text: 'x'.repeat(200)}, {t: 20, text: 'y'.repeat(200)}],
|
||||
b: [{t: 1, text: 'p'.repeat(100)}, {t: 4, text: 'q'.repeat(100)}]};
|
||||
const store = speicher(220);
|
||||
store.map.set(snapKey('b'), JSON.stringify(snaps.b)); /* der Zustand des anderen Fensters */
|
||||
expect(persistSnaps(store, snaps, 'a', store.keys())).toBe(true);
|
||||
/* Der älteste Stand (t:1) war b's — sein Schlüssel im Speicher wurde
|
||||
gekürzt, nicht aus dem Gedächtnis überschrieben; dann räumte der
|
||||
eigene älteste, bis a passte. */
|
||||
expect(snaps.a.map(s => s.t)).toEqual([20]);
|
||||
expect(JSON.parse(store.getItem(snapKey('b'))).map(s => s.t)).toEqual([4]);
|
||||
expect(snaps.b).toBeUndefined(); /* Cache verworfen — beim nächsten Öffnen frisch gelesen */
|
||||
});
|
||||
|
||||
it('speichert unter dem vereinbarten Schlüssel', () => {
|
||||
let key = null;
|
||||
persistSnaps({}, {setItem(k){ key = k; }, removeItem(){}});
|
||||
expect(key).toBe(LS_SNAPS);
|
||||
it('entfernt den fremden Schlüssel ganz, wenn sein letzter Stand gefallen ist', () => {
|
||||
const snaps = {a: [{t: 9, text: 'x'.repeat(200)}, {t: 20, text: 'y'.repeat(200)}],
|
||||
b: [{t: 1, text: 'p'.repeat(100)}]};
|
||||
const store = speicher(220);
|
||||
store.map.set(snapKey('b'), JSON.stringify(snaps.b));
|
||||
expect(persistSnaps(store, snaps, 'a', store.keys())).toBe(true);
|
||||
expect(store.getItem(snapKey('b'))).toBe(null); /* sein letzter Stand ging */
|
||||
});
|
||||
|
||||
it('gibt auf und räumt den eigenen Schlüssel weg, wenn selbst leer nicht passt', () => {
|
||||
const snaps = {a: [{t: 1, text: 'x'}]};
|
||||
const store = speicher(1); /* nicht einmal "[]" passt */
|
||||
expect(persistSnaps(store, snaps, 'a', store.keys())).toBe(false);
|
||||
expect(store.getItem(snapKey('a'))).toBe(null);
|
||||
expect(snaps).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
/* Was aus dem Speicher kommt, ist fremder Text: alte Fassung, von Hand
|
||||
bearbeitet, halb geschrieben. Ein Sicherheitsnetz darf daran nicht die App
|
||||
aufhängen. */
|
||||
describe('parseSnaps — beschädigter Speicher bringt die App nicht um', () => {
|
||||
it('liest die erwartete Form', () => {
|
||||
const o = {a: [{t: 1, text: 'x'}]};
|
||||
expect(parseSnaps(JSON.stringify(o))).toEqual(o);
|
||||
/* Beim Laden werden die Schlüssel gelesen — ein Schlüssel ohne brauchbare
|
||||
Liste fällt still weg. */
|
||||
describe('readAllSnaps / readSnapList — die Stände liegen je Dokument', () => {
|
||||
const store = {
|
||||
getItem: k => ({[snapKey('a')]: '[{"t":1,"text":"x"}]',
|
||||
[snapKey('b')]: '{kaputt',
|
||||
[snapKey('c')]: '[]'}[k] ?? null),
|
||||
};
|
||||
const keys = [snapKey('a'), snapKey('b'), snapKey('c'), 'werkbaum-ui'];
|
||||
|
||||
it('readAllSnaps liest alle Dokumente aus den Schlüsseln', () => {
|
||||
expect(readAllSnaps(store, Object.keys({})).a).toBeUndefined(); /* ohne Schlüsselliste: nichts */
|
||||
expect(Object.keys(readAllSnaps(store, [snapKey('a'), snapKey('b'), snapKey('c'), 'werkbaum-ui'])))
|
||||
.toEqual(['a']);
|
||||
});
|
||||
|
||||
it('readSnapList liest eine Liste, beschädigter Inhalt wird eine leere Liste', () => {
|
||||
expect(readSnapList(store, 'a')).toEqual([{t: 1, text: 'x'}]);
|
||||
expect(readSnapList(store, 'b')).toEqual([]);
|
||||
expect(readSnapList(store, 'gibtsnicht')).toEqual([]);
|
||||
});
|
||||
|
||||
it('parseSnaps wirft Einträge weg, die nicht die erwartete Form haben', () => {
|
||||
const raw = JSON.stringify([{t: 1, text: 'gut'}, {t: 'später', text: 'x'}, {t: 2}, null, {text: 'ohne t'}]);
|
||||
expect(parseSnaps(raw)).toEqual([{t: 1, text: 'gut'}]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['leer', null],
|
||||
['leerer String', ''],
|
||||
['kaputtes JSON', '{nicht json'],
|
||||
['kein Objekt', '"text"'],
|
||||
['Array statt Objekt', '[1,2,3]'],
|
||||
['keine Liste', '"text"'],
|
||||
['Objekt statt Liste', '{"a":1}'],
|
||||
['null', 'null'],
|
||||
])('gibt bei %s ein leeres Objekt zurück', (_name, raw) => {
|
||||
expect(parseSnaps(raw)).toEqual({});
|
||||
])('gibt bei %s eine leere Liste zurück', (_name, raw) => {
|
||||
expect(parseSnaps(raw)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('wirft Einträge weg, die nicht die erwartete Form haben', () => {
|
||||
const raw = JSON.stringify({
|
||||
a: [{t: 1, text: 'gut'}, {t: 'späth', text: 'x'}, {t: 2}, null, {text: 'ohne t'}],
|
||||
b: 'keine Liste',
|
||||
c: []
|
||||
});
|
||||
expect(parseSnaps(raw)).toEqual({a: [{t: 1, text: 'gut'}]});
|
||||
describe('snapKey — der Schlüssel trägt das Präfix des Ablageschemas', () => {
|
||||
it('werkbaum-snaps:<id>', () => {
|
||||
expect(snapKey('example')).toBe(DOC_SNAPS_PREFIX + 'example');
|
||||
expect(snapKey('live:https://x/api/v1/documents/a')).toBe(DOC_SNAPS_PREFIX + 'live:https://x/api/v1/documents/a');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,3 +228,4 @@ describe('snapLabel — heute die Uhrzeit, sonst mit Datum', () => {
|
||||
expect(snapLabel(ts, 'xx-!', ts)).toBe('2026-03-05 14:37');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user