@bobfrankston/rmfmail 1.1.251 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Phase 0 read-isolation fault test (docs/clean-architecture.md §9).
3
+ *
4
+ * Proves two things before the read-worker ships:
5
+ * A. CORRECTNESS — the worker returns byte-identical results to the
6
+ * in-process store for getUnifiedInbox / getMessages / searchMessages.
7
+ * C. ISOLATION — running reads through the worker keeps the MAIN event loop
8
+ * free (low event-loop lag), whereas in-process reads block it for the
9
+ * full duration of each SQLite scan. Main-loop lag is exactly what froze
10
+ * the IPC pump and produced the "nothing in the summary" timeouts.
11
+ *
12
+ * Runs against a COPY of the live DB so it can't collide with a running
13
+ * daemon (two writers on one WAL file = corruption). List reads need only the
14
+ * .db file, not the .eml store.
15
+ *
16
+ * node test/phase0-readworker.mjs
17
+ */
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import os from "node:os";
21
+ import { fileURLToPath } from "node:url";
22
+ import { MailxDB, Store, FileMessageStore } from "../packages/mailx-store/index.js";
23
+ import { spawnDbWorker } from "../packages/mailx-service/db-worker-client.js";
24
+ import { getStorePath } from "../packages/mailx-settings/index.js";
25
+
26
+ const here = path.dirname(fileURLToPath(import.meta.url));
27
+ const liveDir = process.env.RMFMAIL_DIR || path.join(os.homedir(), ".rmfmail");
28
+ const liveDb = path.join(liveDir, "mailx.db");
29
+
30
+ function log(...a) { console.log(...a); }
31
+ function fail(msg) { console.error(" ✗ FAIL:", msg); process.exitCode = 1; }
32
+ function ok(msg) { console.log(" ✓", msg); }
33
+
34
+ // ── set up a temp copy of the live DB ──────────────────────────────────────
35
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "rmf-phase0-"));
36
+ // Body reads resolve body_path relative to the REAL store. Reading .eml files
37
+ // is read-only and safe alongside a running daemon; we never write here.
38
+ const tmpStore = (() => { try { return getStorePath(); } catch { return path.join(tmp, "store"); } })();
39
+ fs.mkdirSync(tmpStore, { recursive: true });
40
+ log(`\nPhase 0 read-worker fault test`);
41
+ log(` live DB : ${liveDb}`);
42
+ log(` temp dir: ${tmp}`);
43
+ if (!fs.existsSync(liveDb)) { fail(`no live DB at ${liveDb}`); process.exit(1); }
44
+ for (const suffix of ["", "-wal", "-shm"]) {
45
+ const src = liveDb + suffix;
46
+ if (fs.existsSync(src)) {
47
+ try { fs.copyFileSync(src, path.join(tmp, "mailx.db" + suffix)); }
48
+ catch (e) { log(` (skip ${suffix || "main"}: ${e.message})`); }
49
+ }
50
+ }
51
+ const sizeMB = (fs.statSync(path.join(tmp, "mailx.db")).size / 1048576).toFixed(0);
52
+ log(` copied ${sizeMB} MB\n`);
53
+
54
+ // ── construct main (writer) store + spawn the read worker ──────────────────
55
+ let mainDb, store, worker;
56
+ try {
57
+ mainDb = new MailxDB(tmp); // runs migrations on the COPY (safe)
58
+ store = new Store(mainDb, new FileMessageStore(tmpStore));
59
+ } catch (e) {
60
+ fail(`could not open copied DB (likely a torn copy of a live WAL): ${e.message}`);
61
+ process.exit(1);
62
+ }
63
+ try {
64
+ worker = await spawnDbWorker({ dbPath: tmp, storePath: tmpStore });
65
+ ok("read-worker spawned + init handshake completed");
66
+ } catch (e) {
67
+ fail(`worker spawn failed: ${e.message}`);
68
+ process.exit(1);
69
+ }
70
+
71
+ // Pick a real folder for getMessages: first account's first folder with rows.
72
+ const accounts = store.getAccounts();
73
+ let probeAccount = null, probeFolderId = null;
74
+ for (const a of accounts) {
75
+ const folders = store.getFolders(a.id);
76
+ for (const f of folders) {
77
+ const r = store.getMessages({ accountId: a.id, folderId: f.id, page: 1, pageSize: 5 });
78
+ if (r.items.length > 0) { probeAccount = a.id; probeFolderId = f.id; break; }
79
+ }
80
+ if (probeFolderId != null) break;
81
+ }
82
+ log(` probe folder: account=${probeAccount} folderId=${probeFolderId}, accounts=${accounts.length}\n`);
83
+
84
+ // ── TEST A: correctness ────────────────────────────────────────────────────
85
+ log("TEST A — worker results match in-process");
86
+ function summarize(r) {
87
+ return {
88
+ total: r.total,
89
+ n: r.items.length,
90
+ firstUid: r.items[0]?.uid ?? null,
91
+ firstSubj: (r.items[0]?.subject ?? "").slice(0, 40),
92
+ lastUid: r.items[r.items.length - 1]?.uid ?? null,
93
+ };
94
+ }
95
+ function eq(label, a, b) {
96
+ const sa = JSON.stringify(summarize(a)), sb = JSON.stringify(summarize(b));
97
+ if (sa === sb) ok(`${label}: identical ${sa}`);
98
+ else fail(`${label}: MISMATCH\n in-proc: ${sa}\n worker : ${sb}`);
99
+ }
100
+
101
+ {
102
+ const ip = store.getUnifiedInbox(1, 50, false);
103
+ const wk = await worker.bus.request("db:getUnifiedInbox", { page: 1, pageSize: 50, flaggedOnly: false });
104
+ eq("getUnifiedInbox(1,50)", ip, wk);
105
+ }
106
+ if (probeFolderId != null) {
107
+ const q = { accountId: probeAccount, folderId: probeFolderId, page: 1, pageSize: 50, sort: "date", sortDir: "desc" };
108
+ const ip = store.getMessages(q);
109
+ const wk = await worker.bus.request("db:getMessages", q);
110
+ eq("getMessages(folder)", ip, wk);
111
+ }
112
+ {
113
+ const ip = store.searchMessages("the", 1, 50, undefined, undefined, false);
114
+ const wk = await worker.bus.request("db:searchMessages", { query: "the", page: 1, pageSize: 50, includeTrashSpam: false });
115
+ eq('searchMessages("the")', ip, wk);
116
+ }
117
+ // getMessage (envelope + cached body parse) — needs the real .eml store.
118
+ if (probeFolderId != null) {
119
+ const list = store.getMessages({ accountId: probeAccount, folderId: probeFolderId, page: 1, pageSize: 20 });
120
+ const withBody = list.items.find(m => m.bodyPath) || list.items[0];
121
+ if (withBody) {
122
+ const ip = await store.getMessage(probeAccount, withBody.uid, false, probeFolderId);
123
+ const wk = await worker.bus.request("db:getMessage", { accountId: probeAccount, uid: withBody.uid, allowRemote: false, folderId: probeFolderId });
124
+ const shape = (m) => m ? { cached: m.cached, htmlLen: (m.bodyHtml || "").length, textLen: (m.bodyText || "").length, atts: (m.attachments || []).length, subj: (m.subject || "").slice(0, 30) } : null;
125
+ const sa = JSON.stringify(shape(ip)), sb = JSON.stringify(shape(wk));
126
+ if (sa === sb) ok(`getMessage(uid=${withBody.uid}): identical ${sa}`);
127
+ else fail(`getMessage: MISMATCH\n in-proc: ${sa}\n worker : ${sb}`);
128
+ } else {
129
+ log(" (getMessage: no probe message available, skipped)");
130
+ }
131
+ }
132
+
133
+ // ── TEST B: raw cost of one in-process read on this DB ──────────────────────
134
+ log("\nTEST B — single-read cost (in-process, on the main thread)");
135
+ {
136
+ const t0 = Date.now();
137
+ store.getUnifiedInbox(1, 50, false);
138
+ const ms = Date.now() - t0;
139
+ log(` one in-process getUnifiedInbox = ${ms} ms (this much main-thread block PER read under the old design)`);
140
+ }
141
+
142
+ // ── TEST C: event-loop lag — in-process vs worker ──────────────────────────
143
+ // A 25ms heartbeat measures how late the event loop fires. Big lag = the
144
+ // main thread (hence the IPC pump, hence the UI) is frozen.
145
+ function lagMonitor() {
146
+ let maxLag = 0, sum = 0, n = 0, last = Date.now();
147
+ const timer = setInterval(() => {
148
+ const now = Date.now();
149
+ const lag = Math.max(0, now - last - 25);
150
+ maxLag = Math.max(maxLag, lag); sum += lag; n++; last = now;
151
+ }, 25);
152
+ timer.unref?.();
153
+ return { stop() { clearInterval(timer); return { maxLag, avgLag: n ? Math.round(sum / n) : 0 }; } };
154
+ }
155
+ const READS = 40;
156
+ async function yield_() { return new Promise(r => setImmediate(r)); }
157
+
158
+ log(`\nTEST C — main event-loop lag during ${READS} reads`);
159
+ // In-process: each read is a synchronous scan on main → main freezes per read.
160
+ {
161
+ const m = lagMonitor();
162
+ for (let i = 0; i < READS; i++) { store.getUnifiedInbox(1, 50, false); await yield_(); }
163
+ const { maxLag, avgLag } = m.stop();
164
+ log(` in-process : maxLag=${maxLag}ms avgLag=${avgLag}ms ← main thread blocked this long`);
165
+ globalThis.__inproc = { maxLag, avgLag };
166
+ }
167
+ // Worker: each read computes on the worker thread → main only relays messages.
168
+ {
169
+ const m = lagMonitor();
170
+ for (let i = 0; i < READS; i++) { await worker.bus.request("db:getUnifiedInbox", { page: 1, pageSize: 50, flaggedOnly: false }); }
171
+ const { maxLag, avgLag } = m.stop();
172
+ log(` worker : maxLag=${maxLag}ms avgLag=${avgLag}ms ← main thread stayed free`);
173
+ globalThis.__worker = { maxLag, avgLag };
174
+ }
175
+ {
176
+ const ip = globalThis.__inproc, wk = globalThis.__worker;
177
+ if (wk.maxLag < ip.maxLag) ok(`worker keeps main loop freer: maxLag ${wk.maxLag}ms vs ${ip.maxLag}ms (${(ip.maxLag / Math.max(1, wk.maxLag)).toFixed(1)}× better)`);
178
+ else fail(`worker did NOT reduce main-loop lag (worker ${wk.maxLag}ms vs in-proc ${ip.maxLag}ms)`);
179
+ }
180
+
181
+ // ── teardown ───────────────────────────────────────────────────────────────
182
+ await worker.close();
183
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* */ }
184
+ log(`\n${process.exitCode ? "RESULT: FAIL" : "RESULT: PASS"}\n`);
185
+ process.exit(process.exitCode || 0);