@bobfrankston/mailx-store 0.1.72 → 0.1.74
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.
- package/db.d.ts +9 -0
- package/db.js +16 -0
- package/package.json +1 -1
- package/store.d.ts +15 -0
- package/store.js +79 -5
package/db.d.ts
CHANGED
|
@@ -320,6 +320,15 @@ export declare class MailxDB {
|
|
|
320
320
|
* so that lookup could miss the row entirely and silently leave the
|
|
321
321
|
* body un-indexed (Bob 2026-05-18: "mcdade" in an opened message's
|
|
322
322
|
* body, search found nothing). */
|
|
323
|
+
/** Length of the indexed FTS body_text for a row (0 = never backfilled
|
|
324
|
+
* or row missing). Cheap rowid lookup — used to skip redundant
|
|
325
|
+
* re-index work on every message view and by the backfill sweep. */
|
|
326
|
+
ftsBodyLength(rowId: number): number;
|
|
327
|
+
/** Cursor page over messages for the FTS body backfill sweep. */
|
|
328
|
+
listBodyRowsAfter(afterId: number, limit: number): Array<{
|
|
329
|
+
id: number;
|
|
330
|
+
bodyPath: string;
|
|
331
|
+
}>;
|
|
323
332
|
updateFtsBody(rowId: number, bodyText: string): void;
|
|
324
333
|
/** List view: messages currently in (account, folder).
|
|
325
334
|
* Joins through `message_folders` so the UID + folder location come
|
package/db.js
CHANGED
|
@@ -1991,6 +1991,22 @@ export class MailxDB {
|
|
|
1991
1991
|
* so that lookup could miss the row entirely and silently leave the
|
|
1992
1992
|
* body un-indexed (Bob 2026-05-18: "mcdade" in an opened message's
|
|
1993
1993
|
* body, search found nothing). */
|
|
1994
|
+
/** Length of the indexed FTS body_text for a row (0 = never backfilled
|
|
1995
|
+
* or row missing). Cheap rowid lookup — used to skip redundant
|
|
1996
|
+
* re-index work on every message view and by the backfill sweep. */
|
|
1997
|
+
ftsBodyLength(rowId) {
|
|
1998
|
+
try {
|
|
1999
|
+
const r = this.db.prepare("SELECT length(body_text) AS l FROM messages_fts WHERE rowid = ?").get(rowId);
|
|
2000
|
+
return Number(r?.l || 0);
|
|
2001
|
+
}
|
|
2002
|
+
catch {
|
|
2003
|
+
return 0;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
/** Cursor page over messages for the FTS body backfill sweep. */
|
|
2007
|
+
listBodyRowsAfter(afterId, limit) {
|
|
2008
|
+
return this.db.prepare("SELECT id, body_path AS bodyPath FROM messages WHERE id > ? ORDER BY id LIMIT ?").all(afterId, limit);
|
|
2009
|
+
}
|
|
1994
2010
|
updateFtsBody(rowId, bodyText) {
|
|
1995
2011
|
if (!rowId)
|
|
1996
2012
|
return;
|
package/package.json
CHANGED
package/store.d.ts
CHANGED
|
@@ -89,6 +89,21 @@ export declare class Store {
|
|
|
89
89
|
private getCachedAllowlist;
|
|
90
90
|
private getCachedSettings;
|
|
91
91
|
invalidateConfigCaches(): void;
|
|
92
|
+
/** Set on read-only Store instances (the db-worker serving UI reads):
|
|
93
|
+
* receives (rowId, bodyText) when getMessage parses a body whose FTS
|
|
94
|
+
* body_text is still empty, so the writable side can apply
|
|
95
|
+
* updateFtsBody. Without this the worker's UPDATE fails on its
|
|
96
|
+
* query_only connection — silently, for months (2026-07-27). */
|
|
97
|
+
onFtsBodyBackfill: ((rowId: number, bodyText: string) => void) | null;
|
|
98
|
+
/** Background sweep: index the body text of every message whose body is
|
|
99
|
+
* on disk but whose FTS row has an empty body_text. Historical debt
|
|
100
|
+
* from the read-only-worker backfill bug — 179k of 190k rows on Bob's
|
|
101
|
+
* store. Paced (one parse per ~100 ms + chunk pauses) so the shared
|
|
102
|
+
* parse-worker lane stays responsive for interactive views; safe to
|
|
103
|
+
* re-run every boot — already-indexed rows cost one rowid lookup and
|
|
104
|
+
* are skipped, so a completed sweep re-scans in seconds. Never runs on
|
|
105
|
+
* a read-only connection. */
|
|
106
|
+
runFtsBodyBackfillSweep(): Promise<void>;
|
|
92
107
|
constructor(
|
|
93
108
|
/** SQLite metadata index. Exposed as a public field — sync clients
|
|
94
109
|
* (mailx-imap, mailx-sync) read/write through it during the
|
package/store.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
import * as fs from "node:fs";
|
|
23
23
|
import { parseSerial } from "./parse-serial.js";
|
|
24
24
|
import { storeBus } from "./bus.js";
|
|
25
|
-
import { sanitizeHtml } from "@bobfrankston/mailx-types";
|
|
25
|
+
import { sanitizeHtml, htmlToPlainText } from "@bobfrankston/mailx-types";
|
|
26
26
|
import { loadSettings, loadAllowlist } from "@bobfrankston/mailx-settings";
|
|
27
27
|
import { sniffAndFixCharset } from "./charset.js";
|
|
28
28
|
/** Parse `List-Unsubscribe` (RFC 2369) and `List-Unsubscribe-Post` (RFC 8058).
|
|
@@ -127,6 +127,61 @@ export class Store {
|
|
|
127
127
|
this._allowlistCache = null;
|
|
128
128
|
this._settingsCache = null;
|
|
129
129
|
}
|
|
130
|
+
/** Set on read-only Store instances (the db-worker serving UI reads):
|
|
131
|
+
* receives (rowId, bodyText) when getMessage parses a body whose FTS
|
|
132
|
+
* body_text is still empty, so the writable side can apply
|
|
133
|
+
* updateFtsBody. Without this the worker's UPDATE fails on its
|
|
134
|
+
* query_only connection — silently, for months (2026-07-27). */
|
|
135
|
+
onFtsBodyBackfill = null;
|
|
136
|
+
/** Background sweep: index the body text of every message whose body is
|
|
137
|
+
* on disk but whose FTS row has an empty body_text. Historical debt
|
|
138
|
+
* from the read-only-worker backfill bug — 179k of 190k rows on Bob's
|
|
139
|
+
* store. Paced (one parse per ~100 ms + chunk pauses) so the shared
|
|
140
|
+
* parse-worker lane stays responsive for interactive views; safe to
|
|
141
|
+
* re-run every boot — already-indexed rows cost one rowid lookup and
|
|
142
|
+
* are skipped, so a completed sweep re-scans in seconds. Never runs on
|
|
143
|
+
* a read-only connection. */
|
|
144
|
+
async runFtsBodyBackfillSweep() {
|
|
145
|
+
if (this.db.readOnly)
|
|
146
|
+
return;
|
|
147
|
+
const CHUNK = 200;
|
|
148
|
+
let last = 0, scanned = 0, indexed = 0, failed = 0;
|
|
149
|
+
const t0 = Date.now();
|
|
150
|
+
for (;;) {
|
|
151
|
+
const rows = this.db.listBodyRowsAfter(last, CHUNK);
|
|
152
|
+
if (rows.length === 0)
|
|
153
|
+
break;
|
|
154
|
+
for (const r of rows) {
|
|
155
|
+
last = r.id;
|
|
156
|
+
scanned++;
|
|
157
|
+
if (!r.bodyPath)
|
|
158
|
+
continue;
|
|
159
|
+
if (this.db.ftsBodyLength(r.id) > 0)
|
|
160
|
+
continue;
|
|
161
|
+
try {
|
|
162
|
+
if (!await this.bodyStore.hasByPath(r.bodyPath))
|
|
163
|
+
continue;
|
|
164
|
+
const raw = await this.bodyStore.readByPath(r.bodyPath);
|
|
165
|
+
const parsed = await parseSerial(sniffAndFixCharset(raw));
|
|
166
|
+
const text = parsed.text || (parsed.html ? htmlToPlainText(parsed.html) : "");
|
|
167
|
+
if (text) {
|
|
168
|
+
this.db.updateFtsBody(r.id, text);
|
|
169
|
+
indexed++;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
failed++;
|
|
174
|
+
}
|
|
175
|
+
// Pace: yield the parse lane between messages so a user click
|
|
176
|
+
// never waits behind more than one sweep parse.
|
|
177
|
+
await new Promise(res => setTimeout(res, 100));
|
|
178
|
+
}
|
|
179
|
+
if (scanned % 5000 < CHUNK) {
|
|
180
|
+
console.log(` [fts-backfill] ${scanned} scanned, ${indexed} indexed, ${failed} failed (${Math.round((Date.now() - t0) / 60000)} min)`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
console.log(` [fts-backfill] sweep complete: ${indexed} bodies indexed, ${failed} failed, ${scanned} rows scanned in ${Math.round((Date.now() - t0) / 60000)} min`);
|
|
184
|
+
}
|
|
130
185
|
constructor(
|
|
131
186
|
/** SQLite metadata index. Exposed as a public field — sync clients
|
|
132
187
|
* (mailx-imap, mailx-sync) read/write through it during the
|
|
@@ -337,11 +392,30 @@ export class Store {
|
|
|
337
392
|
// backfill, searches miss any word that only appears deeper in the
|
|
338
393
|
// body. Fire-and-forget — failures are non-fatal, never block the
|
|
339
394
|
// user's preview render.
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
395
|
+
//
|
|
396
|
+
// HTML-only mail has no parsed.text — derive it from the HTML so
|
|
397
|
+
// those bodies are searchable too (most marketing/newsletter mail).
|
|
398
|
+
//
|
|
399
|
+
// On the READ-ONLY connection (db-worker serving UI reads) the
|
|
400
|
+
// UPDATE cannot run — for months it failed silently here and left
|
|
401
|
+
// body_text empty for every message whose body arrived outside the
|
|
402
|
+
// prefetch path (Bob 2026-07-27: "orwell" found 1 of 6 thread
|
|
403
|
+
// messages; 179k of 190k FTS rows had no body text). Route through
|
|
404
|
+
// onFtsBodyBackfill so the writable main thread applies it.
|
|
405
|
+
const ftsText = bodyText || (bodyHtml ? htmlToPlainText(bodyHtml) : "");
|
|
406
|
+
if (ftsText && envelope.id && this.db.ftsBodyLength(envelope.id) === 0) {
|
|
407
|
+
if (this.db.readOnly) {
|
|
408
|
+
try {
|
|
409
|
+
this.onFtsBodyBackfill?.(envelope.id, ftsText.slice(0, 64_000));
|
|
410
|
+
}
|
|
411
|
+
catch { /* */ }
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
try {
|
|
415
|
+
this.db.updateFtsBody(envelope.id, ftsText);
|
|
416
|
+
}
|
|
417
|
+
catch { /* */ }
|
|
343
418
|
}
|
|
344
|
-
catch { /* */ }
|
|
345
419
|
}
|
|
346
420
|
let hasRemoteContent = false;
|
|
347
421
|
// Filter out "spurious" attachments: mailing-list footers and signature
|