Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_chatkeep.mjs

7.9 KiB, 1 run

created by r2519314175:277, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_chatkeep.mjs — a chat leaves the store on a tombstone, and on nothing
2// else; and a read never overtakes a write of this tab's own.
3//
4// WHY. `ChatStore.write()` deleted by ABSENCE: every id the list it was handed
5// did not mention was removed from the database. Every other merge in this app
6// deletes on a tombstone and nothing else, and there was one reason for the
7// asymmetry — with `localStorage.setItem` of the whole array there was no window
8// in which a list could be short. Transcripts moved to IndexedDB today, and an
9// asynchronous store has one: `save()` sets the in-memory mirror and returns,
10// and a `refresh()` taken before the write behind it lands reads the contents
11// from BEFORE the save and installs them as the mirror. The next caller to build
12// a list out of that mirror hands `write()` a short one, and the chats it left
13// out are deleted, permanently, with no tombstone and nothing said.
14//
15// `applyChats` does exactly that pair — save the merge, then refresh — so the
16// window is not hypothetical. This project has a data-loss incident on record
17// from a field doing double duty (the tags, seq 48), and the rule out of it is
18// that the safe direction is the one that keeps the data.
19//
20// 1. A chat left out of a save with no tombstone SURVIVES.
21// 2. A chat with a tombstone is still deleted — the fix must not turn the
22// store into a place nothing can leave.
23// 3. A tombstone that arrives from the OTHER device deletes here too, because
24// the merge persists it before it saves.
25// 4. `refresh()` does not read past a write of this tab's own.
26//
27// Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and dev/mockllm.mjs
28// (DAIMOND_MOCK_PORT, default 9099). No gateway.
29import { open, chat, errors } from './harness.mjs';
30
31const ok = [], bad = [];
32const check = (name, pass, detail) => {
33 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
34 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
35};
36
37/// The ids the DATABASE holds, read from outside the app so the answer is the
38/// disk's and not the app's belief about it.
39const onDisk = (s) => s.page.evaluate(() => new Promise((res) => {
40 const req = indexedDB.open('daimond-chats', 1);
41 req.onsuccess = () => {
42 const db = req.result;
43 let t;
44 try { t = db.transaction('chats', 'readonly'); } catch (e) { res([]); return; }
45 const out = [];
46 const cur = t.objectStore('chats').openCursor();
47 cur.onsuccess = () => { const c = cur.result; if (c) { out.push(c.value.id); c.continue(); } else res(out.sort()); };
48 cur.onerror = () => res(out.sort());
49 };
50 req.onerror = () => res([]);
51}));
52
53const s = await open({ name: 'chatkeep' });
54const p = s.page;
55
56try {
57 // Three real chats, each with a turn in it, so there is something to lose.
58 await chat(s, '@text keep one');
59 await p.click('#new-session-btn'); await p.waitForTimeout(300);
60 await chat(s, '@text keep two');
61 await p.click('#new-session-btn'); await p.waitForTimeout(300);
62 await chat(s, '@text keep three');
63 await p.waitForTimeout(800);
64
65 const start = await onDisk(s);
66 check('three chats are on disk to begin with', start.length >= 3, start.length + ' rows');
67
68 // ── 1. A short list is not a deletion ──────────────────────────────
69 // The store is handed a list naming ONE of them, with no tombstone for the
70 // others: exactly the shape a refresh() that overtook a write produces.
71 const shortSave = await p.evaluate(async () => {
72 const store = window.DaimondCore.chatStore();
73 const all = store.stored();
74 const one = all.slice(0, 1);
75 store.save(one);
76 await new Promise(r => setTimeout(r, 600));
77 return { asked: one.map(c => c.id), had: all.map(c => c.id).sort() };
78 });
79 const afterShort = await onDisk(s);
80 check('a chat left out of a save with NO tombstone is still on disk',
81 shortSave.had.every(id => afterShort.indexOf(id) !== -1),
82 'asked to hold ' + shortSave.asked.length + ' of ' + shortSave.had.length
83 + ', disk holds ' + afterShort.length);
84
85 // ── 2. A tombstone still deletes ───────────────────────────────────
86 const victim = shortSave.had.find(id => id !== shortSave.asked[0]);
87 await p.evaluate(async (id) => {
88 const k = 'daimond-chats-deleted';
89 const t = JSON.parse(localStorage.getItem(k) || '{}');
90 t[id] = Date.now();
91 localStorage.setItem(k, JSON.stringify(t));
92 const store = window.DaimondCore.chatStore();
93 store.save(store.stored().filter(c => c.id !== id));
94 await new Promise(r => setTimeout(r, 600));
95 }, victim);
96 const afterTomb = await onDisk(s);
97 check('a chat WITH a tombstone is deleted — the store is not a place nothing leaves',
98 afterTomb.indexOf(victim) === -1, victim + ' still present: ' + (afterTomb.indexOf(victim) !== -1));
99 check('and the ones beside it are untouched',
100 shortSave.had.filter(id => id !== victim).every(id => afterTomb.indexOf(id) !== -1),
101 afterTomb.join(','));
102
103 // ── 3. A tombstone from the other device deletes here too ──────────
104 // `applySync` merges the remote tombstone map into localStorage before it
105 // saves, so by the time `write()` looks there is a tombstone to find. Without
106 // that, a deletion made on the other device would never travel.
107 const remoteVictim = afterTomb.find(id => id !== shortSave.asked[0]) || afterTomb[0];
108 await p.evaluate(async (id) => {
109 await window.DaimondCore.applySync({ chats: [], tombs: { [id]: Date.now() } });
110 await new Promise(r => setTimeout(r, 800));
111 }, remoteVictim);
112 const afterRemote = await onDisk(s);
113 check('a deletion made on the OTHER device travels and takes the chat with it',
114 afterRemote.indexOf(remoteVictim) === -1, remoteVictim);
115
116 // ── 4. A read does not overtake a write ────────────────────────────
117 // `save()` then `refresh()` with no wait between them is the pair
118 // `applyChats` makes. The refresh must come back with what was just saved,
119 // not with what was on disk before it.
120 const race = await p.evaluate(async () => {
121 const store = window.DaimondCore.chatStore();
122 const all = store.stored();
123 const born = {
124 id: 'raced-' + Date.now(), name: 'Raced In', messages: [{ role: 'user', content: 'raced', mid: 'r1', ts: Date.now() }],
125 updatedAt: Date.now(), status: 'active',
126 };
127 // TWO saves, the second queued behind the first. That is the shape that
128 // actually loses the read: IndexedDB orders a transaction after the ones
129 // already created, so a read taken while ONE write is in flight is served
130 // after it — but a save still sitting in `queued` has no transaction yet,
131 // and the read goes in front of it and comes back without its contents.
132 store.save(all);
133 store.save(all.concat([born]));
134 // No wait: straight into the read, which is what applyChats does.
135 const read = await store.refresh();
136 // And then the round that follows, built out of whatever the refresh left
137 // in the mirror. THIS is where the loss actually happened: a short mirror
138 // becomes a short list, and a short list used to be a deletion.
139 store.save(store.stored());
140 await new Promise(r => setTimeout(r, 700));
141 return { id: born.id, seen: read.some(c => c.id === born.id), count: read.length };
142 });
143 check('a refresh taken straight after a save comes back with the save in it',
144 race.seen === true, race.id + ' in a mirror of ' + race.count);
145 const afterRace = await onDisk(s);
146 check('and the next save does not delete what the refresh dropped',
147 afterRace.indexOf(race.id) !== -1, afterRace.join(','));
148
149 const errs = errors(s).filter(e => !/favicon|ERR_|Failed to load resource|401|402|502/.test(e));
150 check('nothing else threw', errs.length === 0, errs.slice(0, 2).join(' | '));
151} finally {
152 await s.close();
153}
154
155console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed');
156if (bad.length) { bad.forEach(b => console.log(' FAILED: ' + b)); process.exit(1); }