@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.72",
3
+ "version": "0.1.74",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
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
- if (bodyText && envelope.id) {
341
- try {
342
- this.db.updateFtsBody(envelope.id, bodyText);
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