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:
mhoennig
2026-09-02 20:07:20 +02:00
co-authored by Claude Opus 5
parent 112ada4e58
commit caaf7c15e1
15 changed files with 1633 additions and 395 deletions
+234 -69
View File
@@ -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);
});
});
+161
View File
@@ -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);
});
});
+92 -56
View File
@@ -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');
});
});