@bobfrankston/rmfmail 1.2.18 → 1.2.20

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.
@@ -589,6 +589,24 @@ export class MailxDB {
589
589
  if (cleared > 0) console.log(` [db] cleared ${cleared} prefetch-failure backoff(s) for a fresh session`);
590
590
  } catch { /* non-fatal */ }
591
591
 
592
+ // Per-boot: collapse duplicate memberships — rows where the SAME message
593
+ // holds MULTIPLE uids in the SAME folder (the move-detect accretion bug,
594
+ // Bob 2026-06-16: msg "Re: FYI" had 458 Sent membership rows). Keep the
595
+ // newest (MAX(id)) per (message_row_id, folder_id); the new
596
+ // upsertMessageFolder invariant prevents re-accumulation, this clears the
597
+ // backlog without waiting for each folder to re-sync. Same-folder only —
598
+ // cross-folder memberships (a real move's stale source) are NOT touched
599
+ // here; those need server reconcile to resolve safely.
600
+ try {
601
+ const r = this.db.prepare(
602
+ `DELETE FROM message_folders WHERE id NOT IN (
603
+ SELECT MAX(id) FROM message_folders GROUP BY message_row_id, folder_id
604
+ )`
605
+ ).run();
606
+ const n = Number((r as any).changes || 0);
607
+ if (n > 0) console.log(` [db] collapsed ${n} duplicate same-folder membership row(s)`);
608
+ } catch (e: any) { console.error(` [db] membership collapse failed: ${e?.message || e}`); }
609
+
592
610
  // One-shot cleanup: the retired insertOptimisticSentRow path wrote
593
611
  // synthetic-negative-UID rows into Sent. Those rows are stale (the
594
612
  // real server-synced row eventually appears with a positive UID),
@@ -1555,6 +1573,20 @@ export class MailxDB {
1555
1573
  * (when the server stops listing a UID in a folder). */
1556
1574
  upsertMessageFolder(messageRowId: number, folderId: number, uid: number): void {
1557
1575
  const now = Date.now();
1576
+ // INVARIANT: a message lives at exactly ONE uid per folder. The schema's
1577
+ // UNIQUE(folder_id, uid) only stops the SAME (folder,uid) from dup'ing —
1578
+ // it does NOT stop the SAME message from gaining N rows in ONE folder
1579
+ // under N different uids. That's exactly what happened: move-detect kept
1580
+ // re-binding a message to fresh uids on every Sent sync, so msg 7 ("Re:
1581
+ // FYI") accreted 458 membership rows in Sent alone (Bob 2026-06-16,
1582
+ // "amazon"/"colleagues still in folder"). Before inserting the new
1583
+ // location, drop any OTHER uid this message held in THIS folder — the
1584
+ // newest sighting is the truth; a message can't be at two uids in one
1585
+ // mailbox. This caps membership at one-row-per-(message,folder) and
1586
+ // stops the unbounded accretion.
1587
+ this.db.prepare(
1588
+ "DELETE FROM message_folders WHERE message_row_id = ? AND folder_id = ? AND uid != ?"
1589
+ ).run(messageRowId, folderId, uid);
1558
1590
  // INSERT OR REPLACE on (folder_id, uid) — if some other message_row_id
1559
1591
  // somehow had this slot, replace it. In practice this happens after
1560
1592
  // an EXPUNGE+reinsert: a UID gets reused by the server for a different
@@ -2039,6 +2071,10 @@ export class MailxDB {
2039
2071
  }
2040
2072
 
2041
2073
  /** Unified inbox: all inbox folders across accounts, sorted by date, paginated in SQL */
2074
+ /** Short-TTL cache for the unified-inbox survivor COUNT (the GROUP BY that
2075
+ * scans the whole inbox). Keyed by flaggedOnly+folder-set. */
2076
+ private _unifiedTotalCache = new Map<string, { total: number; at: number }>();
2077
+
2042
2078
  getUnifiedInbox(page = 1, pageSize = 50, flaggedOnly = false): PagedResult<MessageEnvelope> {
2043
2079
  const offset = (page - 1) * pageSize;
2044
2080
  // Find all inbox folder IDs
@@ -2073,48 +2109,78 @@ export class MailxDB {
2073
2109
  // copies the user expects to see in All Inboxes — collapsing across
2074
2110
  // accounts hid the second one (Bob 2026-06-12). Within a single inbox,
2075
2111
  // the to-self N-copies case still collapses as before.
2076
- const total = (this.db.prepare(
2077
- `SELECT COUNT(*) as cnt FROM (
2078
- SELECT 1
2079
- FROM messages m
2080
- JOIN message_folders mf ON mf.message_row_id = m.id
2081
- WHERE mf.folder_id IN (${placeholders})${flagFilter}
2082
- GROUP BY m.account_id, CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
2083
- )`
2084
- ).get(...folderIds) as any).cnt;
2112
+ // total = number of deduped survivors. The GROUP BY scans every inbox
2113
+ // row (~230ms on a 90k inbox) so cache it briefly — it only changes on
2114
+ // sync/delete, and a few seconds of staleness affects only the page-count
2115
+ // indicator, never the (always-live) row data (Bob 2026-06-16).
2116
+ const totalKey = `${flaggedOnly}:${folderIds.join(",")}`;
2117
+ const cachedTotal = this._unifiedTotalCache.get(totalKey);
2118
+ let total: number;
2119
+ if (cachedTotal && (Date.now() - cachedTotal.at) < 4000) {
2120
+ total = cachedTotal.total;
2121
+ } else {
2122
+ total = (this.db.prepare(
2123
+ `SELECT COUNT(*) as cnt FROM (
2124
+ SELECT 1
2125
+ FROM messages m
2126
+ JOIN message_folders mf ON mf.message_row_id = m.id
2127
+ WHERE mf.folder_id IN (${placeholders})${flagFilter}
2128
+ GROUP BY m.account_id, CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
2129
+ )`
2130
+ ).get(...folderIds) as any).cnt;
2131
+ this._unifiedTotalCache.set(totalKey, { total, at: Date.now() });
2132
+ }
2085
2133
 
2086
- const rows = this.db.prepare(
2087
- `WITH ranked AS (
2088
- SELECT m.id AS m_id, mf.uid AS mf_uid, mf.folder_id AS mf_folder_id,
2089
- ROW_NUMBER() OVER (
2090
- PARTITION BY m.account_id, CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
2091
- ORDER BY m.date DESC, m.id DESC
2092
- ) AS rn
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
- )
2097
- SELECT m.*, r.mf_uid AS uid, r.mf_folder_id AS folder_id,
2098
- EXISTS(
2099
- SELECT 1 FROM sync_actions sa
2100
- WHERE sa.account_id = m.account_id AND sa.uid = r.mf_uid
2101
- ) AS pending,
2102
- (SELECT COUNT(DISTINCT account_id) FROM messages m2
2103
- WHERE m2.message_id = m.message_id AND COALESCE(m.message_id, '') != '') AS dupeCount,
2104
- -- Replied state aggregated across the whole Message-ID group, not
2105
- -- just the dedup survivor. When duplicates collapse (same message
2106
- -- delivered to multiple self-addresses), the \Answered flag /
2107
- -- is_replied can live on a NON-surviving copy so the survivor
2108
- -- showed no reply arrow even though TB did. OR it across the group
2109
- -- so the marker is correct regardless of which row wins dedup
2110
- -- (Bob 2026-06-01). Empty message_id → falls back to the row's own.
2111
- COALESCE((SELECT MAX(CASE WHEN m3.is_replied = 1 OR m3.flags_json LIKE '%Answered%' THEN 1 ELSE 0 END)
2112
- FROM messages m3 WHERE m3.message_id = m.message_id AND COALESCE(m.message_id, '') != ''), m.is_replied) AS groupReplied
2113
- FROM ranked r
2114
- JOIN messages m ON m.id = r.m_id
2115
- WHERE r.rn = 1
2116
- ORDER BY m.date DESC LIMIT ? OFFSET ?`
2117
- ).all(...folderIds, pageSize, offset) as any[];
2134
+ // STREAMING DEDUP. The old query ran ROW_NUMBER() OVER (PARTITION BY
2135
+ // message_id ORDER BY date) across EVERY inbox row, then took rn=1 — a
2136
+ // full sort of ~90k rows for a 50-row page (~780ms, Bob 2026-06-16
2137
+ // "getUnifiedInbox 4s/2s"). But page 1 only needs the 50 newest deduped
2138
+ // survivors: walk rows in date-DESC order, keep the first row seen per
2139
+ // (account_id, message_id) group (= the rn=1 survivor — identical rule),
2140
+ // and STOP once the page is filled. Typical page-1 read: ~50-100 physical
2141
+ // rows instead of 90k sorted. .iterate() lets us break early; the index
2142
+ // idx_messages_folder_date supplies the date order. Deep pages walk more
2143
+ // (bounded by offset+pageSize survivors) but remain far cheaper than a
2144
+ // full window sort, and are rare in the unified inbox.
2145
+ const baseStmt = this.db.prepare(
2146
+ `SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id
2147
+ FROM messages m
2148
+ JOIN message_folders mf ON mf.message_row_id = m.id
2149
+ WHERE mf.folder_id IN (${placeholders})${flagFilter}
2150
+ ORDER BY m.date DESC, m.id DESC`
2151
+ );
2152
+ // Per-survivor enrichment (runs only for the ≤pageSize kept rows, not the
2153
+ // whole inbox). Same semantics as the old inline correlated subqueries.
2154
+ const pendingStmt = this.db.prepare(
2155
+ "SELECT 1 FROM sync_actions WHERE account_id = ? AND uid = ? LIMIT 1");
2156
+ const dupeStmt = this.db.prepare(
2157
+ "SELECT COUNT(DISTINCT account_id) AS c FROM messages WHERE message_id = ?");
2158
+ const groupRepliedStmt = this.db.prepare(
2159
+ "SELECT MAX(CASE WHEN is_replied = 1 OR flags_json LIKE '%Answered%' THEN 1 ELSE 0 END) AS r FROM messages WHERE message_id = ?");
2160
+
2161
+ const seen = new Set<string>();
2162
+ const kept: any[] = [];
2163
+ let survivorIdx = 0;
2164
+ const lastWanted = offset + pageSize;
2165
+ for (const r of baseStmt.iterate(...folderIds) as Iterable<any>) {
2166
+ const key = (r.message_id && r.message_id !== "")
2167
+ ? `${r.account_id}${r.message_id}`
2168
+ : `${r.account_id}mid-empty:${r.id}`;
2169
+ if (seen.has(key)) continue; // a newer copy of this group already won
2170
+ seen.add(key);
2171
+ if (survivorIdx >= offset && survivorIdx < lastWanted) kept.push(r);
2172
+ survivorIdx++;
2173
+ if (survivorIdx >= lastWanted) break; // page filled — stop walking
2174
+ }
2175
+
2176
+ const rows = kept.map(r => {
2177
+ const hasMid = !!(r.message_id && r.message_id !== "");
2178
+ r.pending = pendingStmt.get(r.account_id, r.uid) ? 1 : 0;
2179
+ r.dupeCount = hasMid ? ((dupeStmt.get(r.message_id) as any)?.c | 0) : 0;
2180
+ const gr = hasMid ? (groupRepliedStmt.get(r.message_id) as any)?.r : null;
2181
+ r.groupReplied = (gr == null) ? r.is_replied : gr;
2182
+ return r;
2183
+ });
2118
2184
 
2119
2185
  const items: MessageEnvelope[] = rows.map(r => ({
2120
2186
  id: r.id,
@@ -2677,8 +2743,20 @@ export class MailxDB {
2677
2743
  * Returns {busy, log, checkpointed} (log = frames in WAL, checkpointed =
2678
2744
  * frames moved into the db) or null on error. */
2679
2745
  checkpoint(mode: "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE" = "PASSIVE"): { busy: number; log: number; checkpointed: number } | null {
2680
- try { return this.db.prepare(`PRAGMA wal_checkpoint(${mode})`).get() as any; }
2681
- catch { return null; }
2746
+ // TRUNCATE/RESTART need the WAL write lock and otherwise WAIT up to
2747
+ // busy_timeout (5s on main) if a sync write is in flight — blocking the
2748
+ // main thread, the exact stall we are trying to avoid. Drop busy_timeout
2749
+ // to 0 around those modes so the checkpoint returns busy=1 INSTANTLY
2750
+ // instead of waiting; the next 8s tick retries. PASSIVE never waits, so
2751
+ // it keeps the default timeout. (PASSIVE keeps frames checkpointed but
2752
+ // never shrinks the WAL FILE; TRUNCATE is what reclaims the 26 MB the
2753
+ // file grows to during a heavy sync — Bob 2026-06-16.)
2754
+ const needsLock = mode === "TRUNCATE" || mode === "RESTART";
2755
+ try {
2756
+ if (needsLock) this.db.exec("PRAGMA busy_timeout=0");
2757
+ return this.db.prepare(`PRAGMA wal_checkpoint(${mode})`).get() as any;
2758
+ } catch { return null; }
2759
+ finally { if (needsLock) { try { this.db.exec("PRAGMA busy_timeout=5000"); } catch { /* ignore */ } } }
2682
2760
  }
2683
2761
 
2684
2762
  runInTxn<T>(fn: () => T): T {