@bobfrankston/mailx-store 0.1.58 → 0.1.59

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 (2) hide show
  1. package/db.js +59 -4
  2. package/package.json +5 -5
package/db.js CHANGED
@@ -540,6 +540,20 @@ export class MailxDB {
540
540
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_acct_msgid ON messages(account_id, message_id)");
541
541
  }
542
542
  catch { /* already exists */ }
543
+ // GLOBAL date-order indexes for the unified-inbox streaming walk.
544
+ // Every folder-scoped index leads with account_id or folder_id, so a
545
+ // cross-folder `ORDER BY sent_date DESC` had NO usable index and
546
+ // SQLite sorted all ~139k inbox rows into a temp B-tree on EVERY
547
+ // unified page fetch — a fixed ~750ms per call (profiled 2026-07-14;
548
+ // user-visible as "scroll pauses at the bottom then continues", the
549
+ // load-more gap outrunning its 1000px lead). With these, the walk
550
+ // streams straight off the index: ~1ms for a 100-row page. One per
551
+ // date basis (sent_date default, date for the Received toggle).
552
+ try {
553
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_sentdate_global ON messages(sent_date DESC)");
554
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_date_global ON messages(date DESC)");
555
+ }
556
+ catch { /* already exists */ }
543
557
  // is_replied: set when ANY other message in this account has in_reply_to
544
558
  // pointing at this row's message_id. Primary source of truth for the ↩
545
559
  // marker — \Answered is plan B (some servers strip it, Gmail labels
@@ -1836,10 +1850,39 @@ export class MailxDB {
1836
1850
  // fresh insert instead — both copies get their own row, matching
1837
1851
  // what the server (and every other client) actually shows, and the
1838
1852
  // set-diff converges.
1839
- if (moved && moved.folder_id === msg.folderId && moved.uid !== msg.uid
1840
- && msg.liveServerUids?.has(moved.uid)) {
1853
+ const sameFolderDup = !!moved && moved.folder_id === msg.folderId && moved.uid !== msg.uid
1854
+ && !!msg.liveServerUids?.has(moved.uid);
1855
+ // Cross-folder copy, NOT a move (2026-07-16). Self-addressed mail
1856
+ // legitimately carries ONE Message-ID in both INBOX and Sent — a
1857
+ // Sent backfill "moving" the INBOX row to Sent stole thousands of
1858
+ // INBOX rows, and the next INBOX sync saw 877 "server-only" UIDs,
1859
+ // refetched them, rebound them back … an infinite cross-folder
1860
+ // flap that hammered Dovecot, flip-flopped read/unread flags, and
1861
+ // starved the interactive body-fetch lane ("Fetching body from
1862
+ // server…" for minutes). Evidence available at this call site:
1863
+ // the source folder's membership row — its last_seen_at refreshes
1864
+ // every time that folder's sync confirms the UID is still on the
1865
+ // server. A recently-confirmed source instance means the message
1866
+ // exists in BOTH folders → insert a second row (matches server
1867
+ // truth). Reconcile converges either way: a real move's source
1868
+ // membership is dropped by the source folder's next sync and the
1869
+ // orphaned row is deleted. The window covers hot folders (INBOX
1870
+ // confirms every ~30 s); a stale or missing membership falls
1871
+ // through to the rebind, preserving UUID continuity for genuine
1872
+ // server-side moves.
1873
+ let crossFolderCopy = false;
1874
+ if (moved && !sameFolderDup && moved.folder_id !== msg.folderId) {
1875
+ const SOURCE_CONFIRM_WINDOW_MS = 10 * 60_000;
1876
+ const src = this.db.prepare("SELECT message_row_id, last_seen_at FROM message_folders WHERE folder_id = ? AND uid = ?").get(moved.folder_id, moved.uid);
1877
+ crossFolderCopy = !!src && src.message_row_id === moved.id
1878
+ && (Date.now() - src.last_seen_at) < SOURCE_CONFIRM_WINDOW_MS;
1879
+ }
1880
+ if (sameFolderDup && moved) {
1841
1881
  console.log(` [move-detect] ${msg.accountId} ${msg.messageId}: uid ${moved.uid} still on server — duplicate copy at uid ${msg.uid}, inserting second row`);
1842
1882
  }
1883
+ else if (crossFolderCopy && moved) {
1884
+ console.log(` [move-detect] ${msg.accountId} ${msg.messageId}: source folder ${moved.folder_id}/uid ${moved.uid} recently confirmed on server — cross-folder copy at folder ${msg.folderId}/uid ${msg.uid}, inserting second row`);
1885
+ }
1843
1886
  else if (moved) {
1844
1887
  console.log(` [move-detect] ${msg.accountId} ${msg.messageId}: rebinding row ${moved.id} (folder ${moved.folder_id}/uid ${moved.uid} → folder ${msg.folderId}/uid ${msg.uid})`);
1845
1888
  this.db.prepare("UPDATE messages SET folder_id = ?, uid = ?, cached_at = ? WHERE id = ?").run(msg.folderId, msg.uid, Date.now(), moved.id);
@@ -2073,7 +2116,11 @@ export class MailxDB {
2073
2116
  const totalKey = `${flaggedOnly}:${folderIds.join(",")}`;
2074
2117
  const cachedTotal = this._unifiedTotalCache.get(totalKey);
2075
2118
  let total;
2076
- if (cachedTotal && (Date.now() - cachedTotal.at) < 4000) {
2119
+ // 15s TTL (was 4s): the COUNT is a ~450ms GROUP BY scan of every inbox
2120
+ // row, and background refreshes land often enough that a 4s TTL
2121
+ // recomputed it nearly every fetch. Staleness only affects the
2122
+ // page-count indicator / load-more end detection, never row data.
2123
+ if (cachedTotal && (Date.now() - cachedTotal.at) < 15_000) {
2077
2124
  total = cachedTotal.total;
2078
2125
  }
2079
2126
  else {
@@ -2097,9 +2144,17 @@ export class MailxDB {
2097
2144
  // idx_messages_folder_date supplies the date order. Deep pages walk more
2098
2145
  // (bounded by offset+pageSize survivors) but remain far cheaper than a
2099
2146
  // full window sort, and are rare in the unified inbox.
2147
+ // CROSS JOIN forces messages as the OUTER table so the plan drives off
2148
+ // idx_messages_sentdate_global / idx_messages_date_global (global
2149
+ // date-DESC order, no temp B-tree — the `, m.id` tiebreak only sorts
2150
+ // within equal-date runs) and probes message_folders per row. The
2151
+ // planner can't see our early `break`, so with a plain JOIN it
2152
+ // "optimizes" for full output: drives from mf's folder index and
2153
+ // sorts ALL ~139k inbox rows before yielding row 1 (~750ms/page,
2154
+ // profiled 2026-07-14). Forced order: ~1ms.
2100
2155
  const baseStmt = this.db.prepare(`SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id
2101
2156
  FROM messages m
2102
- JOIN message_folders mf ON mf.message_row_id = m.id
2157
+ CROSS JOIN message_folders mf ON mf.message_row_id = m.id
2103
2158
  WHERE mf.folder_id IN (${placeholders})${flagFilter}
2104
2159
  ORDER BY ${dateCol} DESC, m.id DESC`);
2105
2160
  // Per-survivor enrichment (runs only for the ≤pageSize kept rows, not the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.58",
3
+ "version": "0.1.59",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -9,8 +9,8 @@
9
9
  },
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
- "@bobfrankston/mailx-types": "^0.1.22",
13
- "@bobfrankston/mailx-settings": "^0.1.33",
12
+ "@bobfrankston/mailx-types": "^0.1.24",
13
+ "@bobfrankston/mailx-settings": "^0.1.35",
14
14
  "@bobfrankston/mailx-bus": "^0.1.2",
15
15
  "mailparser": "^3.7.2"
16
16
  },
@@ -29,8 +29,8 @@
29
29
  },
30
30
  ".transformedSnapshot": {
31
31
  "dependencies": {
32
- "@bobfrankston/mailx-types": "^0.1.22",
33
- "@bobfrankston/mailx-settings": "^0.1.33",
32
+ "@bobfrankston/mailx-types": "^0.1.24",
33
+ "@bobfrankston/mailx-settings": "^0.1.35",
34
34
  "@bobfrankston/mailx-bus": "^0.1.2",
35
35
  "mailparser": "^3.7.2"
36
36
  }