@bobfrankston/rmfmail 1.2.17 → 1.2.19

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.
@@ -488,6 +488,18 @@ export class MailxDB {
488
488
  try {
489
489
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(account_id, thread_id)");
490
490
  } catch { /* already exists */ }
491
+ // (account_id, message_id) composite — THE hot-path lookup. upsertMessage's
492
+ // move-detect and computeThreadId both query `WHERE account_id=? AND
493
+ // message_id=?`. The old idx_messages_message_id is on message_id ALONE, so
494
+ // with an account_id predicate the planner instead picks an account_id-only
495
+ // index and SCANS every row for the account (Bob's bobma INBOX = 136k rows).
496
+ // One new-message upsert ran several such scans (one per reference); a
497
+ // 50-message batch = the 62-SECOND write txn the slow-txn logger caught
498
+ // (2026-06-16) — which held the write lock and starved every other writer.
499
+ // A leading composite turns each into an O(log n) seek.
500
+ try {
501
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_acct_msgid ON messages(account_id, message_id)");
502
+ } catch { /* already exists */ }
491
503
  // is_replied: set when ANY other message in this account has in_reply_to
492
504
  // pointing at this row's message_id. Primary source of truth for the ↩
493
505
  // marker — \Answered is plan B (some servers strip it, Gmail labels
@@ -2027,6 +2039,10 @@ export class MailxDB {
2027
2039
  }
2028
2040
 
2029
2041
  /** Unified inbox: all inbox folders across accounts, sorted by date, paginated in SQL */
2042
+ /** Short-TTL cache for the unified-inbox survivor COUNT (the GROUP BY that
2043
+ * scans the whole inbox). Keyed by flaggedOnly+folder-set. */
2044
+ private _unifiedTotalCache = new Map<string, { total: number; at: number }>();
2045
+
2030
2046
  getUnifiedInbox(page = 1, pageSize = 50, flaggedOnly = false): PagedResult<MessageEnvelope> {
2031
2047
  const offset = (page - 1) * pageSize;
2032
2048
  // Find all inbox folder IDs
@@ -2061,48 +2077,78 @@ export class MailxDB {
2061
2077
  // copies the user expects to see in All Inboxes — collapsing across
2062
2078
  // accounts hid the second one (Bob 2026-06-12). Within a single inbox,
2063
2079
  // the to-self N-copies case still collapses as before.
2064
- const total = (this.db.prepare(
2065
- `SELECT COUNT(*) as cnt FROM (
2066
- SELECT 1
2067
- FROM messages m
2068
- JOIN message_folders mf ON mf.message_row_id = m.id
2069
- WHERE mf.folder_id IN (${placeholders})${flagFilter}
2070
- GROUP BY m.account_id, CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
2071
- )`
2072
- ).get(...folderIds) as any).cnt;
2080
+ // total = number of deduped survivors. The GROUP BY scans every inbox
2081
+ // row (~230ms on a 90k inbox) so cache it briefly — it only changes on
2082
+ // sync/delete, and a few seconds of staleness affects only the page-count
2083
+ // indicator, never the (always-live) row data (Bob 2026-06-16).
2084
+ const totalKey = `${flaggedOnly}:${folderIds.join(",")}`;
2085
+ const cachedTotal = this._unifiedTotalCache.get(totalKey);
2086
+ let total: number;
2087
+ if (cachedTotal && (Date.now() - cachedTotal.at) < 4000) {
2088
+ total = cachedTotal.total;
2089
+ } else {
2090
+ total = (this.db.prepare(
2091
+ `SELECT COUNT(*) as cnt FROM (
2092
+ SELECT 1
2093
+ FROM messages m
2094
+ JOIN message_folders mf ON mf.message_row_id = m.id
2095
+ WHERE mf.folder_id IN (${placeholders})${flagFilter}
2096
+ GROUP BY m.account_id, CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
2097
+ )`
2098
+ ).get(...folderIds) as any).cnt;
2099
+ this._unifiedTotalCache.set(totalKey, { total, at: Date.now() });
2100
+ }
2073
2101
 
2074
- const rows = this.db.prepare(
2075
- `WITH ranked AS (
2076
- SELECT m.id AS m_id, mf.uid AS mf_uid, mf.folder_id AS mf_folder_id,
2077
- ROW_NUMBER() OVER (
2078
- PARTITION BY m.account_id, CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
2079
- ORDER BY m.date DESC, m.id DESC
2080
- ) AS rn
2081
- FROM messages m
2082
- JOIN message_folders mf ON mf.message_row_id = m.id
2083
- WHERE mf.folder_id IN (${placeholders})${flagFilter}
2084
- )
2085
- SELECT m.*, r.mf_uid AS uid, r.mf_folder_id AS folder_id,
2086
- EXISTS(
2087
- SELECT 1 FROM sync_actions sa
2088
- WHERE sa.account_id = m.account_id AND sa.uid = r.mf_uid
2089
- ) AS pending,
2090
- (SELECT COUNT(DISTINCT account_id) FROM messages m2
2091
- WHERE m2.message_id = m.message_id AND COALESCE(m.message_id, '') != '') AS dupeCount,
2092
- -- Replied state aggregated across the whole Message-ID group, not
2093
- -- just the dedup survivor. When duplicates collapse (same message
2094
- -- delivered to multiple self-addresses), the \Answered flag /
2095
- -- is_replied can live on a NON-surviving copy so the survivor
2096
- -- showed no reply arrow even though TB did. OR it across the group
2097
- -- so the marker is correct regardless of which row wins dedup
2098
- -- (Bob 2026-06-01). Empty message_id → falls back to the row's own.
2099
- COALESCE((SELECT MAX(CASE WHEN m3.is_replied = 1 OR m3.flags_json LIKE '%Answered%' THEN 1 ELSE 0 END)
2100
- FROM messages m3 WHERE m3.message_id = m.message_id AND COALESCE(m.message_id, '') != ''), m.is_replied) AS groupReplied
2101
- FROM ranked r
2102
- JOIN messages m ON m.id = r.m_id
2103
- WHERE r.rn = 1
2104
- ORDER BY m.date DESC LIMIT ? OFFSET ?`
2105
- ).all(...folderIds, pageSize, offset) as any[];
2102
+ // STREAMING DEDUP. The old query ran ROW_NUMBER() OVER (PARTITION BY
2103
+ // message_id ORDER BY date) across EVERY inbox row, then took rn=1 — a
2104
+ // full sort of ~90k rows for a 50-row page (~780ms, Bob 2026-06-16
2105
+ // "getUnifiedInbox 4s/2s"). But page 1 only needs the 50 newest deduped
2106
+ // survivors: walk rows in date-DESC order, keep the first row seen per
2107
+ // (account_id, message_id) group (= the rn=1 survivor — identical rule),
2108
+ // and STOP once the page is filled. Typical page-1 read: ~50-100 physical
2109
+ // rows instead of 90k sorted. .iterate() lets us break early; the index
2110
+ // idx_messages_folder_date supplies the date order. Deep pages walk more
2111
+ // (bounded by offset+pageSize survivors) but remain far cheaper than a
2112
+ // full window sort, and are rare in the unified inbox.
2113
+ const baseStmt = this.db.prepare(
2114
+ `SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id
2115
+ FROM messages m
2116
+ JOIN message_folders mf ON mf.message_row_id = m.id
2117
+ WHERE mf.folder_id IN (${placeholders})${flagFilter}
2118
+ ORDER BY m.date DESC, m.id DESC`
2119
+ );
2120
+ // Per-survivor enrichment (runs only for the ≤pageSize kept rows, not the
2121
+ // whole inbox). Same semantics as the old inline correlated subqueries.
2122
+ const pendingStmt = this.db.prepare(
2123
+ "SELECT 1 FROM sync_actions WHERE account_id = ? AND uid = ? LIMIT 1");
2124
+ const dupeStmt = this.db.prepare(
2125
+ "SELECT COUNT(DISTINCT account_id) AS c FROM messages WHERE message_id = ?");
2126
+ const groupRepliedStmt = this.db.prepare(
2127
+ "SELECT MAX(CASE WHEN is_replied = 1 OR flags_json LIKE '%Answered%' THEN 1 ELSE 0 END) AS r FROM messages WHERE message_id = ?");
2128
+
2129
+ const seen = new Set<string>();
2130
+ const kept: any[] = [];
2131
+ let survivorIdx = 0;
2132
+ const lastWanted = offset + pageSize;
2133
+ for (const r of baseStmt.iterate(...folderIds) as Iterable<any>) {
2134
+ const key = (r.message_id && r.message_id !== "")
2135
+ ? `${r.account_id}${r.message_id}`
2136
+ : `${r.account_id}mid-empty:${r.id}`;
2137
+ if (seen.has(key)) continue; // a newer copy of this group already won
2138
+ seen.add(key);
2139
+ if (survivorIdx >= offset && survivorIdx < lastWanted) kept.push(r);
2140
+ survivorIdx++;
2141
+ if (survivorIdx >= lastWanted) break; // page filled — stop walking
2142
+ }
2143
+
2144
+ const rows = kept.map(r => {
2145
+ const hasMid = !!(r.message_id && r.message_id !== "");
2146
+ r.pending = pendingStmt.get(r.account_id, r.uid) ? 1 : 0;
2147
+ r.dupeCount = hasMid ? ((dupeStmt.get(r.message_id) as any)?.c | 0) : 0;
2148
+ const gr = hasMid ? (groupRepliedStmt.get(r.message_id) as any)?.r : null;
2149
+ r.groupReplied = (gr == null) ? r.is_replied : gr;
2150
+ return r;
2151
+ });
2106
2152
 
2107
2153
  const items: MessageEnvelope[] = rows.map(r => ({
2108
2154
  id: r.id,
@@ -2665,8 +2711,20 @@ export class MailxDB {
2665
2711
  * Returns {busy, log, checkpointed} (log = frames in WAL, checkpointed =
2666
2712
  * frames moved into the db) or null on error. */
2667
2713
  checkpoint(mode: "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE" = "PASSIVE"): { busy: number; log: number; checkpointed: number } | null {
2668
- try { return this.db.prepare(`PRAGMA wal_checkpoint(${mode})`).get() as any; }
2669
- catch { return null; }
2714
+ // TRUNCATE/RESTART need the WAL write lock and otherwise WAIT up to
2715
+ // busy_timeout (5s on main) if a sync write is in flight — blocking the
2716
+ // main thread, the exact stall we are trying to avoid. Drop busy_timeout
2717
+ // to 0 around those modes so the checkpoint returns busy=1 INSTANTLY
2718
+ // instead of waiting; the next 8s tick retries. PASSIVE never waits, so
2719
+ // it keeps the default timeout. (PASSIVE keeps frames checkpointed but
2720
+ // never shrinks the WAL FILE; TRUNCATE is what reclaims the 26 MB the
2721
+ // file grows to during a heavy sync — Bob 2026-06-16.)
2722
+ const needsLock = mode === "TRUNCATE" || mode === "RESTART";
2723
+ try {
2724
+ if (needsLock) this.db.exec("PRAGMA busy_timeout=0");
2725
+ return this.db.prepare(`PRAGMA wal_checkpoint(${mode})`).get() as any;
2726
+ } catch { return null; }
2727
+ finally { if (needsLock) { try { this.db.exec("PRAGMA busy_timeout=5000"); } catch { /* ignore */ } } }
2670
2728
  }
2671
2729
 
2672
2730
  runInTxn<T>(fn: () => T): T {