@bobfrankston/mailx-store 0.1.42 → 0.1.44

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.
Files changed (3) hide show
  1. package/db.d.ts +11 -1
  2. package/db.js +46 -2
  3. package/package.json +3 -3
package/db.d.ts CHANGED
@@ -371,10 +371,20 @@ export declare class MailxDB {
371
371
  * reply arrives. Idempotent — UPDATE on an already-1 row is a no-op. */
372
372
  markRepliedByMessageId(accountId: string, messageId: string): boolean;
373
373
  /** Get messages without cached bodies (for background prefetch) */
374
- getMessagesWithoutBody(accountId: string, limit?: number): {
374
+ getMessagesWithoutBody(accountId: string, limit?: number, excludeFolderIds?: number[]): {
375
375
  uid: number;
376
376
  folderId: number;
377
377
  }[];
378
+ /** Record a prefetch failure (0-body fetch / store-write fail) for a UID,
379
+ * incrementing its backoff count. Persisted so it survives restarts. */
380
+ recordPrefetchFailure(accountId: string, folderId: number, uid: number): void;
381
+ /** Read a UID's prefetch-failure record (count + lastTried), or null. */
382
+ getPrefetchFailure(accountId: string, folderId: number, uid: number): {
383
+ count: number;
384
+ lastTried: number;
385
+ } | null;
386
+ /** Clear a UID's prefetch-failure record after a successful fetch. */
387
+ clearPrefetchFailure(accountId: string, folderId: number, uid: number): void;
378
388
  /** Highest server UID we've seen in this folder. After the
379
389
  * message_folders refactor, this reads from the membership table
380
390
  * rather than messages.uid — a server-side move from this folder
package/db.js CHANGED
@@ -315,6 +315,23 @@ const SCHEMA = `
315
315
  PRIMARY KEY(scope, key)
316
316
  );
317
317
 
318
+ -- Prefetch-failure backoff, PERSISTED (was an in-memory Map that every
319
+ -- daemon restart wiped — so un-fetchable "ghost" UIDs, e.g. stale Outbox
320
+ -- rows for already-sent messages that return 0 bodies, got re-tried in
321
+ -- full on every launch and re-clogged the size-asc prefetch queue,
322
+ -- starving healthy folders. "So much unfetched, never drains" — Bob
323
+ -- 2026-06-01. Persisting the count+timestamp lets the 5min→12h backoff
324
+ -- survive restarts so zombies stay sidelined. Pure cache-control state;
325
+ -- safe to lose (worst case = one extra retry), so no reconstruction needed.
326
+ CREATE TABLE IF NOT EXISTS prefetch_failures (
327
+ account_id TEXT NOT NULL,
328
+ folder_id INTEGER NOT NULL,
329
+ uid INTEGER NOT NULL,
330
+ count INTEGER NOT NULL,
331
+ last_tried INTEGER NOT NULL,
332
+ PRIMARY KEY(account_id, folder_id, uid)
333
+ );
334
+
318
335
  -- Audit trail of every destructive DB operation. Writes ONLY — the row
319
336
  -- inserted here records the deletion the caller is about to (or just
320
337
  -- did) commit on the messages table. Lets the user retroactively answer
@@ -1998,14 +2015,41 @@ export class MailxDB {
1998
2015
  return r.changes > 0;
1999
2016
  }
2000
2017
  /** Get messages without cached bodies (for background prefetch) */
2001
- getMessagesWithoutBody(accountId, limit = 50) {
2018
+ getMessagesWithoutBody(accountId, limit = 50, excludeFolderIds = []) {
2002
2019
  // Prefetch order: smallest first, NULLs last, recent within tiebreak.
2003
2020
  // Reasoning: on slow / metered Android networks, the user feels the
2004
2021
  // cache "fill up" much faster when the queue is dominated by short
2005
2022
  // notification mails (a handful of KB each) instead of a single
2006
2023
  // multi-megabyte attachment that monopolizes bandwidth for minutes.
2007
2024
  // Size 0 / NULL fall to the end so they don't masquerade as small.
2008
- return this.db.prepare("SELECT uid, folder_id as folderId FROM messages WHERE account_id = ? AND (body_path IS NULL OR body_path = '') ORDER BY (size IS NULL OR size = 0), size ASC, date DESC LIMIT ?").all(accountId, limit);
2025
+ //
2026
+ // excludeFolderIds: folders the caller has put in error-cooldown. We
2027
+ // exclude them HERE (at the query) rather than after slicing, because a
2028
+ // single bloated/failing folder (e.g. a server-side Outbox stuffed with
2029
+ // hundreds of stuck messages) would otherwise fill the entire size-asc
2030
+ // result and starve every healthy folder of its prefetch turn — the
2031
+ // "so much unfetched, nothing draining" symptom (Bob 2026-06-01).
2032
+ const exclusion = excludeFolderIds.length
2033
+ ? ` AND folder_id NOT IN (${excludeFolderIds.map(() => "?").join(",")})`
2034
+ : "";
2035
+ return this.db.prepare(`SELECT uid, folder_id as folderId FROM messages WHERE account_id = ? AND (body_path IS NULL OR body_path = '')${exclusion} ORDER BY (size IS NULL OR size = 0), size ASC, date DESC LIMIT ?`).all(accountId, ...excludeFolderIds, limit);
2036
+ }
2037
+ /** Record a prefetch failure (0-body fetch / store-write fail) for a UID,
2038
+ * incrementing its backoff count. Persisted so it survives restarts. */
2039
+ recordPrefetchFailure(accountId, folderId, uid) {
2040
+ this.db.prepare(`INSERT INTO prefetch_failures (account_id, folder_id, uid, count, last_tried)
2041
+ VALUES (?, ?, ?, 1, ?)
2042
+ ON CONFLICT(account_id, folder_id, uid)
2043
+ DO UPDATE SET count = count + 1, last_tried = excluded.last_tried`).run(accountId, folderId, uid, Date.now());
2044
+ }
2045
+ /** Read a UID's prefetch-failure record (count + lastTried), or null. */
2046
+ getPrefetchFailure(accountId, folderId, uid) {
2047
+ const r = this.db.prepare("SELECT count, last_tried AS lastTried FROM prefetch_failures WHERE account_id = ? AND folder_id = ? AND uid = ?").get(accountId, folderId, uid);
2048
+ return r ? { count: r.count, lastTried: r.lastTried } : null;
2049
+ }
2050
+ /** Clear a UID's prefetch-failure record after a successful fetch. */
2051
+ clearPrefetchFailure(accountId, folderId, uid) {
2052
+ this.db.prepare("DELETE FROM prefetch_failures WHERE account_id = ? AND folder_id = ? AND uid = ?").run(accountId, folderId, uid);
2009
2053
  }
2010
2054
  /** Highest server UID we've seen in this folder. After the
2011
2055
  * message_folders refactor, this reads from the membership table
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.42",
3
+ "version": "0.1.44",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -10,7 +10,7 @@
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@bobfrankston/mailx-types": "^0.1.18",
13
- "@bobfrankston/mailx-settings": "^0.1.25",
13
+ "@bobfrankston/mailx-settings": "^0.1.26",
14
14
  "@bobfrankston/mailx-bus": "^0.1.2",
15
15
  "mailparser": "^3.7.2"
16
16
  },
@@ -30,7 +30,7 @@
30
30
  ".transformedSnapshot": {
31
31
  "dependencies": {
32
32
  "@bobfrankston/mailx-types": "^0.1.18",
33
- "@bobfrankston/mailx-settings": "^0.1.25",
33
+ "@bobfrankston/mailx-settings": "^0.1.26",
34
34
  "@bobfrankston/mailx-bus": "^0.1.2",
35
35
  "mailparser": "^3.7.2"
36
36
  }