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. |
| 29 | import { open, chat, errors } from './harness.mjs'; |
| 30 | |
| 31 | const ok = [], bad = []; |
| 32 | const 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. |
| 39 | const 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 | |
| 53 | const s = await open({ name: 'chatkeep' }); |
| 54 | const p = s.page; |
| 55 | |
| 56 | try { |
| 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 | |
| 155 | console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed'); |
| 156 | if (bad.length) { bad.forEach(b => console.log(' FAILED: ' + b)); process.exit(1); } |