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);
});
});