@bobfrankston/mailx-store 0.1.53 → 0.1.55

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 +15 -1
  2. package/db.js +69 -38
  3. package/package.json +3 -3
package/db.d.ts CHANGED
@@ -435,6 +435,20 @@ export declare class MailxDB {
435
435
  * here but NOT on the server gets its membership dropped (which
436
436
  * may then GC the messages row if it has no other folders). */
437
437
  getUidsForFolder(_accountId: string, folderId: number): number[];
438
+ /** UIDs we actually have a stored MESSAGE ROW for in this folder — from the
439
+ * `messages` table, NOT `message_folders`. The two can diverge: an "orphan
440
+ * membership" (a message_folders row whose messages row was never written /
441
+ * got deleted) makes getUidsForFolder report a UID we don't truly have.
442
+ * The set-diff backfill must diff against THIS so it re-fetches the real
443
+ * message data for orphans instead of treating them as present (Bob
444
+ * 2026-06-20: ~1k old INBOX messages stuck missing — membership said
445
+ * "have it", the row didn't exist). */
446
+ getStoredUids(accountId: string, folderId: number): number[];
447
+ /** Count of stored MESSAGE ROWS in a folder (messages table). The deficit
448
+ * gate compares this to the server's count to decide whether to reconcile;
449
+ * using the messages table (not message_folders) means orphan memberships
450
+ * don't mask a real "rows are missing" deficit. */
451
+ getStoredCount(accountId: string, folderId: number): number;
438
452
  /** List recent rows in a folder for a Sent-sweep / reconciliation pass.
439
453
  * Returns only the columns the sweeper needs (uid, message_id, date,
440
454
  * subject, size, has_attachments, preview, body_path) for every row in
@@ -559,7 +573,7 @@ export declare class MailxDB {
559
573
  * click, queued behind it; that IS the "loading body takes forever"
560
574
  * bug). Now keyset-paginated by `m.id` with a `setImmediate` yield
561
575
  * between pages, so user IPC lands in the gaps. */
562
- seedContactsFromMessages(): Promise<number>;
576
+ seedContactsFromMessages(force?: boolean): Promise<number>;
563
577
  /** Apply the contents of contacts.jsonc — replaces all preferred-tier rows
564
578
  * with the entries in `preferred[]`, merges `discovered[]` into the local
565
579
  * cache, sets the in-memory denylist, and purges any discovered rows
package/db.js CHANGED
@@ -633,6 +633,10 @@ export class MailxDB {
633
633
  catch (e) {
634
634
  console.error(` [db] membership collapse failed: ${e?.message || e}`);
635
635
  }
636
+ // (The per-boot cross-folder collapse for "exclusive" accounts was
637
+ // REMOVED 2026-06-16 — it deleted intentional copies. Cross-folder stale
638
+ // memberships are now cleaned by server-truth reconcile, which keeps real
639
+ // copies. See upsertMessageFolder's note.)
636
640
  // One-shot cleanup: the retired insertOptimisticSentRow path wrote
637
641
  // synthetic-negative-UID rows into Sent. Those rows are stale (the
638
642
  // real server-synced row eventually appears with a positive UID),
@@ -1495,27 +1499,24 @@ export class MailxDB {
1495
1499
  * (when the server stops listing a UID in a folder). */
1496
1500
  upsertMessageFolder(messageRowId, folderId, uid, exclusive = false) {
1497
1501
  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
- }
1502
+ // NOTE: `exclusive` (drop-all-other-folder-memberships) was REVERTED
1503
+ // 2026-06-16. It deletes INTENTIONAL copies — Bob copies messages into
1504
+ // multiple folders, so the same Message-ID legitimately lives in several
1505
+ // places, and exclusivity would flip-flop a real copy between its folders
1506
+ // every sync. Cross-folder stale-move-source memberships are cleaned by
1507
+ // server-truth RECONCILE (the folder's sync drops uids the server no
1508
+ // longer lists) which preserves copies (server confirms both) while
1509
+ // dropping accidents (server confirms one). The param is kept for the
1510
+ // signature but no longer drops cross-folder rows.
1511
+ void exclusive;
1512
+ // INVARIANT (all accounts): a message lives at exactly ONE uid per
1513
+ // folder. UNIQUE(folder_id, uid) only stops the SAME (folder,uid)
1514
+ // dup'ing — not the SAME message gaining N rows in ONE folder under N
1515
+ // different uids (move-detect re-binding to fresh uids each sync
1516
+ // msg 7 "Re: FYI" had 458 Sent rows). Drop any OTHER uid this message
1517
+ // held in THIS folder before inserting the new one. (Same-folder only
1518
+ // never touches other folders, so copies survive.)
1519
+ this.db.prepare("DELETE FROM message_folders WHERE message_row_id = ? AND folder_id = ? AND uid != ?").run(messageRowId, folderId, uid);
1519
1520
  // INSERT OR REPLACE on (folder_id, uid) — if some other message_row_id
1520
1521
  // somehow had this slot, replace it. In practice this happens after
1521
1522
  // an EXPUNGE+reinsert: a UID gets reused by the server for a different
@@ -1710,26 +1711,17 @@ export class MailxDB {
1710
1711
  // stacks) that point at the UUID. Only kicks in when messageId is
1711
1712
  // present (servers usually include it; if not we fall through to a
1712
1713
  // fresh insert which mints a new UUID).
1714
+ //
1715
+ // NOTE (2026-06-16): the instance-model refactor (docs/instance-model-plan.md)
1716
+ // will REPLACE this collapse with per-(folder,uid) instances. That change
1717
+ // is held until the full model + a rebuild land — shipping it alone would
1718
+ // surface transient move-dups. Keeping the collapse for now.
1713
1719
  if (msg.messageId) {
1714
1720
  const moved = this.db.prepare("SELECT id, folder_id, uid FROM messages WHERE account_id = ? AND message_id = ? LIMIT 1").get(msg.accountId, msg.messageId);
1715
1721
  if (moved) {
1716
1722
  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})`);
1717
- // Update folder_id + uid on the messages row (additive
1718
- // migration: keeps the "primary location" columns valid for
1719
- // existing reads). The new schema's source of truth is
1720
- // message_folders — see below.
1721
1723
  this.db.prepare("UPDATE messages SET folder_id = ?, uid = ?, cached_at = ? WHERE id = ?").run(msg.folderId, msg.uid, Date.now(), moved.id);
1722
- // Add the new membership row. The OLD membership (source
1723
- // folder + old uid) stays — reconcile in the source folder
1724
- // is what eventually drops it (when the server stops
1725
- // listing the UID there). For Gmail multi-label messages,
1726
- // both memberships persist legitimately.
1727
1724
  this.upsertMessageFolder(moved.id, msg.folderId, msg.uid, msg.exclusive);
1728
- // Notify subscribers so e.g. the reconciler can cancel any
1729
- // pending deferred-delete for the original (folder, uid) —
1730
- // otherwise the reconcile-delete grace timer fires for a
1731
- // row that's been rebound elsewhere, and the user sees the
1732
- // moved message vanish 30 minutes after the move.
1733
1725
  if (this._onMoveDetected) {
1734
1726
  try {
1735
1727
  this._onMoveDetected({
@@ -2402,6 +2394,26 @@ export class MailxDB {
2402
2394
  const rows = this.db.prepare("SELECT uid FROM message_folders WHERE folder_id = ?").all(folderId);
2403
2395
  return rows.map(r => r.uid);
2404
2396
  }
2397
+ /** UIDs we actually have a stored MESSAGE ROW for in this folder — from the
2398
+ * `messages` table, NOT `message_folders`. The two can diverge: an "orphan
2399
+ * membership" (a message_folders row whose messages row was never written /
2400
+ * got deleted) makes getUidsForFolder report a UID we don't truly have.
2401
+ * The set-diff backfill must diff against THIS so it re-fetches the real
2402
+ * message data for orphans instead of treating them as present (Bob
2403
+ * 2026-06-20: ~1k old INBOX messages stuck missing — membership said
2404
+ * "have it", the row didn't exist). */
2405
+ getStoredUids(accountId, folderId) {
2406
+ const rows = this.db.prepare("SELECT uid FROM messages WHERE account_id = ? AND folder_id = ?").all(accountId, folderId);
2407
+ return rows.map(r => r.uid);
2408
+ }
2409
+ /** Count of stored MESSAGE ROWS in a folder (messages table). The deficit
2410
+ * gate compares this to the server's count to decide whether to reconcile;
2411
+ * using the messages table (not message_folders) means orphan memberships
2412
+ * don't mask a real "rows are missing" deficit. */
2413
+ getStoredCount(accountId, folderId) {
2414
+ const r = this.db.prepare("SELECT count(*) as cnt FROM messages WHERE account_id = ? AND folder_id = ?").get(accountId, folderId);
2415
+ return r?.cnt || 0;
2416
+ }
2405
2417
  /** List recent rows in a folder for a Sent-sweep / reconciliation pass.
2406
2418
  * Returns only the columns the sweeper needs (uid, message_id, date,
2407
2419
  * subject, size, has_attachments, preview, body_path) for every row in
@@ -2656,9 +2668,19 @@ export class MailxDB {
2656
2668
  * click, queued behind it; that IS the "loading body takes forever"
2657
2669
  * bug). Now keyset-paginated by `m.id` with a `setImmediate` yield
2658
2670
  * between pages, so user IPC lands in the gaps. */
2659
- async seedContactsFromMessages() {
2671
+ async seedContactsFromMessages(force = false) {
2660
2672
  const VALID = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
2661
2673
  const now = Date.now();
2674
+ // INCREMENTAL via a persisted high-water-mark on messages.id. The old
2675
+ // behaviour re-aggregated ALL ~180k messages and re-wrote the ENTIRE
2676
+ // contacts table every 30 min — 1–2s write txns that locked out the sync
2677
+ // worker ("database is locked", Bob 2026-06-16 "locked"). Now each run
2678
+ // only scans rows with id > the last-seeded id, so a periodic run touches
2679
+ // just the handful of messages that arrived since — a tiny, fast write.
2680
+ // First run (no watermark) and `force` do the full scan. The watermark
2681
+ // resets to 0 on rebuild (cache wiped), so a rebuild re-seeds fully.
2682
+ const startId = force ? 0 : Number(this.getKv("contacts", "seed_high_id") || "0");
2683
+ let maxSeenId = startId;
2662
2684
  const agg = new Map();
2663
2685
  const bump = (name, address, date) => {
2664
2686
  const email = (address || "").trim().toLowerCase();
@@ -2709,7 +2731,7 @@ export class MailxDB {
2709
2731
  JOIN folders f ON m.folder_id = f.id
2710
2732
  WHERE f.special_use = 'sent' AND m.id > ?
2711
2733
  ORDER BY m.id LIMIT ?`);
2712
- let lastId = 0;
2734
+ let lastId = startId;
2713
2735
  for (;;) {
2714
2736
  const rows = stmt.all(lastId, PAGE);
2715
2737
  if (rows.length === 0)
@@ -2721,6 +2743,8 @@ export class MailxDB {
2721
2743
  eatRecipients(r.bcc_json, date);
2722
2744
  }
2723
2745
  lastId = rows[rows.length - 1].id;
2746
+ if (lastId > maxSeenId)
2747
+ maxSeenId = lastId;
2724
2748
  if (rows.length < PAGE)
2725
2749
  break;
2726
2750
  await yieldLoop();
@@ -2733,7 +2757,7 @@ export class MailxDB {
2733
2757
  LEFT JOIN folders f ON m.folder_id = f.id
2734
2758
  WHERE (f.special_use IS NULL OR f.special_use != 'sent') AND m.id > ?
2735
2759
  ORDER BY m.id LIMIT ?`);
2736
- let lastId = 0;
2760
+ let lastId = startId;
2737
2761
  for (;;) {
2738
2762
  const rows = stmt.all(lastId, PAGE);
2739
2763
  if (rows.length === 0)
@@ -2746,6 +2770,8 @@ export class MailxDB {
2746
2770
  eatRecipients(r.bcc_json, date);
2747
2771
  }
2748
2772
  lastId = rows[rows.length - 1].id;
2773
+ if (lastId > maxSeenId)
2774
+ maxSeenId = lastId;
2749
2775
  if (rows.length < PAGE)
2750
2776
  break;
2751
2777
  await yieldLoop();
@@ -2794,6 +2820,11 @@ export class MailxDB {
2794
2820
  console.log(` [contacts] seed: ${added} new + ${bumped} refreshed (discovered)`);
2795
2821
  this.notifyContactsChanged();
2796
2822
  }
2823
+ // Advance the high-water-mark so the next run only scans messages newer
2824
+ // than the highest id we just processed. Monotonic — never regress (a
2825
+ // concurrent insert mid-run keeps a higher max for the next pass).
2826
+ if (maxSeenId > startId)
2827
+ this.setKv("contacts", "seed_high_id", String(maxSeenId));
2797
2828
  return added;
2798
2829
  }
2799
2830
  /** Apply the contents of contacts.jsonc — replaces all preferred-tier rows
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.53",
3
+ "version": "0.1.55",
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.19",
13
- "@bobfrankston/mailx-settings": "^0.1.28",
13
+ "@bobfrankston/mailx-settings": "^0.1.30",
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.19",
33
- "@bobfrankston/mailx-settings": "^0.1.28",
33
+ "@bobfrankston/mailx-settings": "^0.1.30",
34
34
  "@bobfrankston/mailx-bus": "^0.1.2",
35
35
  "mailparser": "^3.7.2"
36
36
  }