Files
werkbaum/frontend/src/docsync.js
T
mhoennigandClaude Opus 5 caaf7c15e1 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>
2026-09-02 20:07:20 +02:00

137 lines
6.8 KiB
JavaScript

/* docsync — Nachziehen im laufenden Fenster (RFC 002 §6.3, headless).
Zwei Fenster teilen sich einen Ursprung und damit den Speicher; das
`storage`-Ereignis feuert nur in den FREMDEN Fenstern und nennt Schlüssel,
alten und neuen Wert — für Liste, Namen und Tombstones die vollständige
Information. Diese Funktion entscheidet rein, was ein Ereignis bedeutet;
app.js wendet das Ergebnis an (Menü zeichnen, Chip, Warnung).
Heikel sind die Fälle am AKTIVEN Dokument (§5.3):
- anderswo umbenannt → übernehmen, der Chip folgt (Text unberührt);
- anderswo gelöscht → behalten, bis getippt wird — Tippen ist Absicht
(D55-Linie); sofortiges Wegschalten zöge Text unter der Schreibmarke weg;
- anderswo Text geschrieben → nicht nachziehen (es überschriebe das
Getippte), sondern warnen (§6.6) — der Restfall „dasselbe nicht geteilte
Dokument in beiden Fenstern". Bei `live:` ist der Feed die Quelle:
nichts tun.
headless (D54-Nachtrag 3): keine DOM-, keine Speicher-Zugriffe — das
Ereignis kommt als Datenobjekt herein, die Liste als Datenarray. */
import { LS_DOCS, DOC_TEXT_PREFIX, DOC_META_PREFIX, DOC_GONE_PREFIX,
DOC_SNAPS_PREFIX, GONE_TTL } from './docstore.js';
export { GONE_TTL };
/* Geteilte Dokumente (`live:`) werden NIE gesperrt: Zwei Fenster sind zwei
Live-Clients mit eigener Kennung, der Server führt zusammen (D76) — genau
der Fall, den der alte D89-Dialog verbot, obwohl er funktioniert. Nicht
„lokal", sondern „nicht `live:`" ist das Kriterium (§5.4): URL- und
Datei-Dokumente sind ebenso im Browser wahr. */
export function lockDecision(id){
return !String(id == null ? '' : id).startsWith('live:');
}
/* Der Name der Sperre je Dokument (§6.4). */
export function lockName(id){ return 'werkbaum-doc:' + String(id); }
/* Ist ein Tombstone verfallen? Ohne brauchbaren Zeitstempel gilt er als
verfallen — ein Sicherheitsnetz darf nicht zum Datenfriedhof werden. */
export function tombstoneExpired(ts, now){
return !(typeof ts === 'number' && ts > 0) || (now - ts) > GONE_TTL;
}
/* Dirty-Menge (§6.1): was DIESSES Fenster angelegt, umbenannt, getippt hat.
Die Flush-Punkte schreiben nur diese Dokumente plus den Index-Hinweis —
kein Voll-Flush mehr, der fremde Schlüssel anfassen könnte. */
export function createDirtySet(){
const s = new Set();
return {
add(id){ if(id) s.add(String(id)); },
has(id){ return s.has(String(id)); },
entries(){ return [...s]; },
clear(){ s.clear(); },
};
}
/* Ein storage-Ereignis auf die Dokumentenliste anwenden.
Rückgabe `{docs, action, id}`: `docs` ist DASSELBE Array, wenn nichts an
der Liste geändert hat (Mutationen am Eintrag — Umbenennen — laufen auf dem
übergebenen Objekt), sonst ein neues. `action` sagt, was am AKTIVEN
Dokument zu tun ist:
- 'created' — meta neu: Dokument hängt in der Liste, Text vom
Aufrufer lazy aus dem Speicher lesen
- 'renamed' — Name/Quelle des aktiven Dokuments übernommen (Chip)
- 'deleted' — aktives Dokument wurde anderswo gelöscht: BEHALTEN,
warnfarben markieren (§6.5), bis getippt wird
- 'foreignWrite' — das aktive nicht-`live:`-Dokument wurde anderswo
geschrieben: der Restfall, Warnung (§6.6)
- 'snaps' — Stände-Cache der id verwerfen (§6.3)
- 'order' — Reihenfolge-Hinweis übernommen
- null — nichts am aktiven Dokument */
export function applyStorageEvent(docs, activeId, ev){
const key = String((ev && ev.key) || '');
const neu = ev ? ev.newValue : null;
if(key.startsWith(DOC_META_PREFIX)){
const id = key.slice(DOC_META_PREFIX.length);
if(!id || neu == null) return {docs, action: null, id: null};
let meta = null;
try{ meta = JSON.parse(neu); }catch(_){ meta = null; }
if(!meta || typeof meta !== 'object' || Array.isArray(meta)) return {docs, action: null, id};
const d = docs.find(x => x.id === id);
if(!d){
const aus = {id, name: typeof meta.name === 'string' && meta.name ? meta.name : id, text: ''};
if(typeof meta.source === 'string' && meta.source) aus.source = meta.source;
return {docs: docs.concat([aus]), action: 'created', id};
}
let geaendert = false;
if(typeof meta.name === 'string' && meta.name && d.name !== meta.name){ d.name = meta.name; geaendert = true; }
if(typeof meta.source === 'string' && meta.source && d.source !== meta.source){ d.source = meta.source; geaendert = true; }
return {docs, action: geaendert && id === activeId ? 'renamed' : null, id};
}
if(key.startsWith(DOC_GONE_PREFIX)){
const id = key.slice(DOC_GONE_PREFIX.length);
if(!id || neu == null) return {docs, action: null, id}; /* Tombstone aufgehoben — das meta-Ereignis folgt */
if(!docs.some(x => x.id === id)) return {docs, action: null, id};
if(id === activeId) return {docs, action: 'deleted', id}; /* behalten, warnfarben (§6.5) */
return {docs: docs.filter(x => x.id !== id), action: null, id};
}
if(key.startsWith(DOC_TEXT_PREFIX)){
const id = key.slice(DOC_TEXT_PREFIX.length);
if(!id || neu == null) return {docs, action: null, id}; /* Entfernen kommt als gone-Ereignis */
const d = docs.find(x => x.id === id);
if(!d) return {docs, action: null, id}; /* das meta-Ereignis legt ihn an */
if(id === activeId){
return lockDecision(id) ? {docs, action: 'foreignWrite', id}
: {docs, action: null, id}; /* der Feed ist die Quelle */
}
d.text = neu; /* Vorschau nachziehen */
return {docs, action: null, id};
}
if(key.startsWith(DOC_SNAPS_PREFIX)){
return {docs, action: 'snaps', id: key.slice(DOC_SNAPS_PREFIX.length)};
}
if(key === LS_DOCS){
/* Reihenfolge-Hinweis (§6.1): übernehmen, wo er etwas sagt. Bekannte ids
rücken in seine Reihenfolge, unbekannte bleiben hinten — in ihrer
bisherigen Ordnung. */
let arr = null;
try{ arr = JSON.parse(neu || 'null'); }catch(_){ arr = null; }
if(!Array.isArray(arr)) return {docs, action: null, id: null};
const pos = new Map();
arr.forEach((e, i) => { if(e && typeof e.id === 'string') pos.set(e.id, i); });
if(!pos.size) return {docs, action: null, id: null};
const geordnet = docs.slice().sort((a, b) => {
const ra = pos.has(a.id) ? pos.get(a.id) : arr.length + docs.indexOf(a);
const rb = pos.has(b.id) ? pos.get(b.id) : arr.length + docs.indexOf(b);
return ra - rb;
});
const gleich = geordnet.every((d, i) => d === docs[i]);
return {docs: gleich ? docs : geordnet, action: gleich ? null : 'order', id: null};
}
/* werkbaum-active, werkbaum-src, Ansicht, Merker — letzter Schreiber
gewinnt, harmlos (§3). */
return {docs, action: null, id: null};
}