@bobfrankston/mailx-store 0.1.52 → 0.1.53

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 +8 -1
  2. package/db.js +145 -41
  3. package/package.json +1 -1
package/db.d.ts CHANGED
@@ -226,7 +226,7 @@ export declare class MailxDB {
226
226
  * insert / move-detect / existing-row upsert during the additive
227
227
  * migration. Reconcile is the only path that DELETES from this table
228
228
  * (when the server stops listing a UID in a folder). */
229
- upsertMessageFolder(messageRowId: number, folderId: number, uid: number): void;
229
+ upsertMessageFolder(messageRowId: number, folderId: number, uid: number, exclusive?: boolean): void;
230
230
  /** Drop a membership row when reconcile finds the UID is no longer
231
231
  * on the server in this folder. The messages row itself is NOT
232
232
  * touched — it may still exist in other folders, or the deferred-
@@ -262,6 +262,10 @@ export declare class MailxDB {
262
262
  preview: string;
263
263
  bodyPath: string;
264
264
  providerId?: string;
265
+ /** Exclusive-folder account (IMAP): enforce one-folder-per-message in
266
+ * message_folders. Omitted/false for Gmail (label = folder, many per
267
+ * message). Set true by the IMAP storeMessages path. */
268
+ exclusive?: boolean;
265
269
  }): number;
266
270
  /** Backfill the FTS5 `body_text` column for a message after its body
267
271
  * has been parsed. Capped at ~64 KB of text per row — FTS5 stores the
@@ -283,6 +287,9 @@ export declare class MailxDB {
283
287
  * legacy columns are frozen at first-sight values. */
284
288
  getMessages(query: MessageQuery): PagedResult<MessageEnvelope>;
285
289
  /** Unified inbox: all inbox folders across accounts, sorted by date, paginated in SQL */
290
+ /** Short-TTL cache for the unified-inbox survivor COUNT (the GROUP BY that
291
+ * scans the whole inbox). Keyed by flaggedOnly+folder-set. */
292
+ private _unifiedTotalCache;
286
293
  getUnifiedInbox(page?: number, pageSize?: number, flaggedOnly?: boolean): PagedResult<MessageEnvelope>;
287
294
  /** Map a `messages` row to a MessageEnvelope. Exposes `uuid` (stable local
288
295
  * identity) and `bodyPath` (authoritative on-disk location) in addition
package/db.js CHANGED
@@ -508,6 +508,19 @@ export class MailxDB {
508
508
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(account_id, thread_id)");
509
509
  }
510
510
  catch { /* already exists */ }
511
+ // (account_id, message_id) composite — THE hot-path lookup. upsertMessage's
512
+ // move-detect and computeThreadId both query `WHERE account_id=? AND
513
+ // message_id=?`. The old idx_messages_message_id is on message_id ALONE, so
514
+ // with an account_id predicate the planner instead picks an account_id-only
515
+ // index and SCANS every row for the account (Bob's bobma INBOX = 136k rows).
516
+ // One new-message upsert ran several such scans (one per reference); a
517
+ // 50-message batch = the 62-SECOND write txn the slow-txn logger caught
518
+ // (2026-06-16) — which held the write lock and starved every other writer.
519
+ // A leading composite turns each into an O(log n) seek.
520
+ try {
521
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_acct_msgid ON messages(account_id, message_id)");
522
+ }
523
+ catch { /* already exists */ }
511
524
  // is_replied: set when ANY other message in this account has in_reply_to
512
525
  // pointing at this row's message_id. Primary source of truth for the ↩
513
526
  // marker — \Answered is plan B (some servers strip it, Gmail labels
@@ -601,6 +614,25 @@ export class MailxDB {
601
614
  console.log(` [db] cleared ${cleared} prefetch-failure backoff(s) for a fresh session`);
602
615
  }
603
616
  catch { /* non-fatal */ }
617
+ // Per-boot: collapse duplicate memberships — rows where the SAME message
618
+ // holds MULTIPLE uids in the SAME folder (the move-detect accretion bug,
619
+ // Bob 2026-06-16: msg "Re: FYI" had 458 Sent membership rows). Keep the
620
+ // newest (MAX(id)) per (message_row_id, folder_id); the new
621
+ // upsertMessageFolder invariant prevents re-accumulation, this clears the
622
+ // backlog without waiting for each folder to re-sync. Same-folder only —
623
+ // cross-folder memberships (a real move's stale source) are NOT touched
624
+ // here; those need server reconcile to resolve safely.
625
+ try {
626
+ const r = this.db.prepare(`DELETE FROM message_folders WHERE id NOT IN (
627
+ SELECT MAX(id) FROM message_folders GROUP BY message_row_id, folder_id
628
+ )`).run();
629
+ const n = Number(r.changes || 0);
630
+ if (n > 0)
631
+ console.log(` [db] collapsed ${n} duplicate same-folder membership row(s)`);
632
+ }
633
+ catch (e) {
634
+ console.error(` [db] membership collapse failed: ${e?.message || e}`);
635
+ }
604
636
  // One-shot cleanup: the retired insertOptimisticSentRow path wrote
605
637
  // synthetic-negative-UID rows into Sent. Those rows are stale (the
606
638
  // real server-synced row eventually appears with a positive UID),
@@ -1461,8 +1493,29 @@ export class MailxDB {
1461
1493
  * insert / move-detect / existing-row upsert during the additive
1462
1494
  * migration. Reconcile is the only path that DELETES from this table
1463
1495
  * (when the server stops listing a UID in a folder). */
1464
- upsertMessageFolder(messageRowId, folderId, uid) {
1496
+ upsertMessageFolder(messageRowId, folderId, uid, exclusive = false) {
1465
1497
  const now = Date.now();
1498
+ if (exclusive) {
1499
+ // EXCLUSIVE-folder account (IMAP — Bob's iecc): a message lives in
1500
+ // exactly ONE folder. Drop EVERY other membership for this message
1501
+ // before recording the current one; the folder that most recently
1502
+ // saw it (this sync) is its real location. This both prevents the
1503
+ // cross-folder "moved but still in source" accretion (Bob 2026-06-16
1504
+ // "colleagues still in main folder") and self-heals the backlog as
1505
+ // each folder syncs — only the folder that genuinely holds the
1506
+ // message on the server will ever see+set it. NOT used for Gmail,
1507
+ // where one message legitimately carries several label-folders.
1508
+ this.db.prepare("DELETE FROM message_folders WHERE message_row_id = ? AND NOT (folder_id = ? AND uid = ?)").run(messageRowId, folderId, uid);
1509
+ }
1510
+ else {
1511
+ // INVARIANT (all accounts): a message lives at exactly ONE uid per
1512
+ // folder. UNIQUE(folder_id, uid) only stops the SAME (folder,uid)
1513
+ // dup'ing — not the SAME message gaining N rows in ONE folder under N
1514
+ // different uids (move-detect re-binding to fresh uids each sync →
1515
+ // msg 7 "Re: FYI" had 458 Sent rows). Drop any OTHER uid this message
1516
+ // held in THIS folder before inserting the new one.
1517
+ this.db.prepare("DELETE FROM message_folders WHERE message_row_id = ? AND folder_id = ? AND uid != ?").run(messageRowId, folderId, uid);
1518
+ }
1466
1519
  // INSERT OR REPLACE on (folder_id, uid) — if some other message_row_id
1467
1520
  // somehow had this slot, replace it. In practice this happens after
1468
1521
  // an EXPUNGE+reinsert: a UID gets reused by the server for a different
@@ -1646,7 +1699,7 @@ export class MailxDB {
1646
1699
  // is still in this folder. No-op if migration already populated
1647
1700
  // it; defensive INSERT-OR-UPDATE handles the race where the row
1648
1701
  // was created without a corresponding membership.
1649
- this.upsertMessageFolder(existing.id, msg.folderId, msg.uid);
1702
+ this.upsertMessageFolder(existing.id, msg.folderId, msg.uid, msg.exclusive);
1650
1703
  return existing.id;
1651
1704
  }
1652
1705
  // Move-detection: if this Message-ID already exists for this account
@@ -1671,7 +1724,7 @@ export class MailxDB {
1671
1724
  // is what eventually drops it (when the server stops
1672
1725
  // listing the UID there). For Gmail multi-label messages,
1673
1726
  // both memberships persist legitimately.
1674
- this.upsertMessageFolder(moved.id, msg.folderId, msg.uid);
1727
+ this.upsertMessageFolder(moved.id, msg.folderId, msg.uid, msg.exclusive);
1675
1728
  // Notify subscribers so e.g. the reconciler can cancel any
1676
1729
  // pending deferred-delete for the original (folder, uid) —
1677
1730
  // otherwise the reconcile-delete grace timer fires for a
@@ -1729,7 +1782,7 @@ export class MailxDB {
1729
1782
  // source of truth — old folder_id/uid columns above are kept in
1730
1783
  // sync during the additive migration but reads will move to JOIN
1731
1784
  // with message_folders.
1732
- this.upsertMessageFolder(rowId, msg.folderId, msg.uid);
1785
+ this.upsertMessageFolder(rowId, msg.folderId, msg.uid, msg.exclusive);
1733
1786
  // Index for full-text search. body_text seeded from `msg.preview`
1734
1787
  // here — the full parsed body isn't available at upsert time (we
1735
1788
  // store .eml on disk; parsing is on-demand). LocalStore.getMessage
@@ -1856,6 +1909,9 @@ export class MailxDB {
1856
1909
  return { items, total, page, pageSize };
1857
1910
  }
1858
1911
  /** Unified inbox: all inbox folders across accounts, sorted by date, paginated in SQL */
1912
+ /** Short-TTL cache for the unified-inbox survivor COUNT (the GROUP BY that
1913
+ * scans the whole inbox). Keyed by flaggedOnly+folder-set. */
1914
+ _unifiedTotalCache = new Map();
1859
1915
  getUnifiedInbox(page = 1, pageSize = 50, flaggedOnly = false) {
1860
1916
  const offset = (page - 1) * pageSize;
1861
1917
  // Find all inbox folder IDs
@@ -1887,43 +1943,72 @@ export class MailxDB {
1887
1943
  // copies the user expects to see in All Inboxes — collapsing across
1888
1944
  // accounts hid the second one (Bob 2026-06-12). Within a single inbox,
1889
1945
  // the to-self N-copies case still collapses as before.
1890
- const total = this.db.prepare(`SELECT COUNT(*) as cnt FROM (
1891
- SELECT 1
1892
- FROM messages m
1893
- JOIN message_folders mf ON mf.message_row_id = m.id
1894
- WHERE mf.folder_id IN (${placeholders})${flagFilter}
1895
- GROUP BY m.account_id, CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
1896
- )`).get(...folderIds).cnt;
1897
- const rows = this.db.prepare(`WITH ranked AS (
1898
- SELECT m.id AS m_id, mf.uid AS mf_uid, mf.folder_id AS mf_folder_id,
1899
- ROW_NUMBER() OVER (
1900
- PARTITION BY m.account_id, CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
1901
- ORDER BY m.date DESC, m.id DESC
1902
- ) AS rn
1903
- FROM messages m
1904
- JOIN message_folders mf ON mf.message_row_id = m.id
1905
- WHERE mf.folder_id IN (${placeholders})${flagFilter}
1906
- )
1907
- SELECT m.*, r.mf_uid AS uid, r.mf_folder_id AS folder_id,
1908
- EXISTS(
1909
- SELECT 1 FROM sync_actions sa
1910
- WHERE sa.account_id = m.account_id AND sa.uid = r.mf_uid
1911
- ) AS pending,
1912
- (SELECT COUNT(DISTINCT account_id) FROM messages m2
1913
- WHERE m2.message_id = m.message_id AND COALESCE(m.message_id, '') != '') AS dupeCount,
1914
- -- Replied state aggregated across the whole Message-ID group, not
1915
- -- just the dedup survivor. When duplicates collapse (same message
1916
- -- delivered to multiple self-addresses), the \Answered flag /
1917
- -- is_replied can live on a NON-surviving copy so the survivor
1918
- -- showed no reply arrow even though TB did. OR it across the group
1919
- -- so the marker is correct regardless of which row wins dedup
1920
- -- (Bob 2026-06-01). Empty message_id falls back to the row's own.
1921
- COALESCE((SELECT MAX(CASE WHEN m3.is_replied = 1 OR m3.flags_json LIKE '%Answered%' THEN 1 ELSE 0 END)
1922
- FROM messages m3 WHERE m3.message_id = m.message_id AND COALESCE(m.message_id, '') != ''), m.is_replied) AS groupReplied
1923
- FROM ranked r
1924
- JOIN messages m ON m.id = r.m_id
1925
- WHERE r.rn = 1
1926
- ORDER BY m.date DESC LIMIT ? OFFSET ?`).all(...folderIds, pageSize, offset);
1946
+ // total = number of deduped survivors. The GROUP BY scans every inbox
1947
+ // row (~230ms on a 90k inbox) so cache it briefly — it only changes on
1948
+ // sync/delete, and a few seconds of staleness affects only the page-count
1949
+ // indicator, never the (always-live) row data (Bob 2026-06-16).
1950
+ const totalKey = `${flaggedOnly}:${folderIds.join(",")}`;
1951
+ const cachedTotal = this._unifiedTotalCache.get(totalKey);
1952
+ let total;
1953
+ if (cachedTotal && (Date.now() - cachedTotal.at) < 4000) {
1954
+ total = cachedTotal.total;
1955
+ }
1956
+ else {
1957
+ total = this.db.prepare(`SELECT COUNT(*) as cnt FROM (
1958
+ SELECT 1
1959
+ FROM messages m
1960
+ JOIN message_folders mf ON mf.message_row_id = m.id
1961
+ WHERE mf.folder_id IN (${placeholders})${flagFilter}
1962
+ GROUP BY m.account_id, CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
1963
+ )`).get(...folderIds).cnt;
1964
+ this._unifiedTotalCache.set(totalKey, { total, at: Date.now() });
1965
+ }
1966
+ // STREAMING DEDUP. The old query ran ROW_NUMBER() OVER (PARTITION BY
1967
+ // message_id ORDER BY date) across EVERY inbox row, then took rn=1 — a
1968
+ // full sort of ~90k rows for a 50-row page (~780ms, Bob 2026-06-16
1969
+ // "getUnifiedInbox 4s/2s"). But page 1 only needs the 50 newest deduped
1970
+ // survivors: walk rows in date-DESC order, keep the first row seen per
1971
+ // (account_id, message_id) group (= the rn=1 survivor identical rule),
1972
+ // and STOP once the page is filled. Typical page-1 read: ~50-100 physical
1973
+ // rows instead of 90k sorted. .iterate() lets us break early; the index
1974
+ // idx_messages_folder_date supplies the date order. Deep pages walk more
1975
+ // (bounded by offset+pageSize survivors) but remain far cheaper than a
1976
+ // full window sort, and are rare in the unified inbox.
1977
+ const baseStmt = this.db.prepare(`SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id
1978
+ FROM messages m
1979
+ JOIN message_folders mf ON mf.message_row_id = m.id
1980
+ WHERE mf.folder_id IN (${placeholders})${flagFilter}
1981
+ ORDER BY m.date DESC, m.id DESC`);
1982
+ // Per-survivor enrichment (runs only for the ≤pageSize kept rows, not the
1983
+ // whole inbox). Same semantics as the old inline correlated subqueries.
1984
+ const pendingStmt = this.db.prepare("SELECT 1 FROM sync_actions WHERE account_id = ? AND uid = ? LIMIT 1");
1985
+ const dupeStmt = this.db.prepare("SELECT COUNT(DISTINCT account_id) AS c FROM messages WHERE message_id = ?");
1986
+ const groupRepliedStmt = this.db.prepare("SELECT MAX(CASE WHEN is_replied = 1 OR flags_json LIKE '%Answered%' THEN 1 ELSE 0 END) AS r FROM messages WHERE message_id = ?");
1987
+ const seen = new Set();
1988
+ const kept = [];
1989
+ let survivorIdx = 0;
1990
+ const lastWanted = offset + pageSize;
1991
+ for (const r of baseStmt.iterate(...folderIds)) {
1992
+ const key = (r.message_id && r.message_id !== "")
1993
+ ? `${r.account_id}${r.message_id}`
1994
+ : `${r.account_id}mid-empty:${r.id}`;
1995
+ if (seen.has(key))
1996
+ continue; // a newer copy of this group already won
1997
+ seen.add(key);
1998
+ if (survivorIdx >= offset && survivorIdx < lastWanted)
1999
+ kept.push(r);
2000
+ survivorIdx++;
2001
+ if (survivorIdx >= lastWanted)
2002
+ break; // page filled — stop walking
2003
+ }
2004
+ const rows = kept.map(r => {
2005
+ const hasMid = !!(r.message_id && r.message_id !== "");
2006
+ r.pending = pendingStmt.get(r.account_id, r.uid) ? 1 : 0;
2007
+ r.dupeCount = hasMid ? (dupeStmt.get(r.message_id)?.c | 0) : 0;
2008
+ const gr = hasMid ? groupRepliedStmt.get(r.message_id)?.r : null;
2009
+ r.groupReplied = (gr == null) ? r.is_replied : gr;
2010
+ return r;
2011
+ });
1927
2012
  const items = rows.map(r => ({
1928
2013
  id: r.id,
1929
2014
  accountId: r.account_id,
@@ -2418,12 +2503,31 @@ export class MailxDB {
2418
2503
  * Returns {busy, log, checkpointed} (log = frames in WAL, checkpointed =
2419
2504
  * frames moved into the db) or null on error. */
2420
2505
  checkpoint(mode = "PASSIVE") {
2506
+ // TRUNCATE/RESTART need the WAL write lock and otherwise WAIT up to
2507
+ // busy_timeout (5s on main) if a sync write is in flight — blocking the
2508
+ // main thread, the exact stall we are trying to avoid. Drop busy_timeout
2509
+ // to 0 around those modes so the checkpoint returns busy=1 INSTANTLY
2510
+ // instead of waiting; the next 8s tick retries. PASSIVE never waits, so
2511
+ // it keeps the default timeout. (PASSIVE keeps frames checkpointed but
2512
+ // never shrinks the WAL FILE; TRUNCATE is what reclaims the 26 MB the
2513
+ // file grows to during a heavy sync — Bob 2026-06-16.)
2514
+ const needsLock = mode === "TRUNCATE" || mode === "RESTART";
2421
2515
  try {
2516
+ if (needsLock)
2517
+ this.db.exec("PRAGMA busy_timeout=0");
2422
2518
  return this.db.prepare(`PRAGMA wal_checkpoint(${mode})`).get();
2423
2519
  }
2424
2520
  catch {
2425
2521
  return null;
2426
2522
  }
2523
+ finally {
2524
+ if (needsLock) {
2525
+ try {
2526
+ this.db.exec("PRAGMA busy_timeout=5000");
2527
+ }
2528
+ catch { /* ignore */ }
2529
+ }
2530
+ }
2427
2531
  }
2428
2532
  runInTxn(fn) {
2429
2533
  if (this.db.isTransaction)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.52",
3
+ "version": "0.1.53",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",