@bobfrankston/mailx-store 0.1.53 → 0.1.54

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 +1 -1
  2. package/db.js +49 -38
  3. package/package.json +1 -1
package/db.d.ts CHANGED
@@ -559,7 +559,7 @@ export declare class MailxDB {
559
559
  * click, queued behind it; that IS the "loading body takes forever"
560
560
  * bug). Now keyset-paginated by `m.id` with a `setImmediate` yield
561
561
  * between pages, so user IPC lands in the gaps. */
562
- seedContactsFromMessages(): Promise<number>;
562
+ seedContactsFromMessages(force?: boolean): Promise<number>;
563
563
  /** Apply the contents of contacts.jsonc — replaces all preferred-tier rows
564
564
  * with the entries in `preferred[]`, merges `discovered[]` into the local
565
565
  * 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({
@@ -2656,9 +2648,19 @@ export class MailxDB {
2656
2648
  * click, queued behind it; that IS the "loading body takes forever"
2657
2649
  * bug). Now keyset-paginated by `m.id` with a `setImmediate` yield
2658
2650
  * between pages, so user IPC lands in the gaps. */
2659
- async seedContactsFromMessages() {
2651
+ async seedContactsFromMessages(force = false) {
2660
2652
  const VALID = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
2661
2653
  const now = Date.now();
2654
+ // INCREMENTAL via a persisted high-water-mark on messages.id. The old
2655
+ // behaviour re-aggregated ALL ~180k messages and re-wrote the ENTIRE
2656
+ // contacts table every 30 min — 1–2s write txns that locked out the sync
2657
+ // worker ("database is locked", Bob 2026-06-16 "locked"). Now each run
2658
+ // only scans rows with id > the last-seeded id, so a periodic run touches
2659
+ // just the handful of messages that arrived since — a tiny, fast write.
2660
+ // First run (no watermark) and `force` do the full scan. The watermark
2661
+ // resets to 0 on rebuild (cache wiped), so a rebuild re-seeds fully.
2662
+ const startId = force ? 0 : Number(this.getKv("contacts", "seed_high_id") || "0");
2663
+ let maxSeenId = startId;
2662
2664
  const agg = new Map();
2663
2665
  const bump = (name, address, date) => {
2664
2666
  const email = (address || "").trim().toLowerCase();
@@ -2709,7 +2711,7 @@ export class MailxDB {
2709
2711
  JOIN folders f ON m.folder_id = f.id
2710
2712
  WHERE f.special_use = 'sent' AND m.id > ?
2711
2713
  ORDER BY m.id LIMIT ?`);
2712
- let lastId = 0;
2714
+ let lastId = startId;
2713
2715
  for (;;) {
2714
2716
  const rows = stmt.all(lastId, PAGE);
2715
2717
  if (rows.length === 0)
@@ -2721,6 +2723,8 @@ export class MailxDB {
2721
2723
  eatRecipients(r.bcc_json, date);
2722
2724
  }
2723
2725
  lastId = rows[rows.length - 1].id;
2726
+ if (lastId > maxSeenId)
2727
+ maxSeenId = lastId;
2724
2728
  if (rows.length < PAGE)
2725
2729
  break;
2726
2730
  await yieldLoop();
@@ -2733,7 +2737,7 @@ export class MailxDB {
2733
2737
  LEFT JOIN folders f ON m.folder_id = f.id
2734
2738
  WHERE (f.special_use IS NULL OR f.special_use != 'sent') AND m.id > ?
2735
2739
  ORDER BY m.id LIMIT ?`);
2736
- let lastId = 0;
2740
+ let lastId = startId;
2737
2741
  for (;;) {
2738
2742
  const rows = stmt.all(lastId, PAGE);
2739
2743
  if (rows.length === 0)
@@ -2746,6 +2750,8 @@ export class MailxDB {
2746
2750
  eatRecipients(r.bcc_json, date);
2747
2751
  }
2748
2752
  lastId = rows[rows.length - 1].id;
2753
+ if (lastId > maxSeenId)
2754
+ maxSeenId = lastId;
2749
2755
  if (rows.length < PAGE)
2750
2756
  break;
2751
2757
  await yieldLoop();
@@ -2794,6 +2800,11 @@ export class MailxDB {
2794
2800
  console.log(` [contacts] seed: ${added} new + ${bumped} refreshed (discovered)`);
2795
2801
  this.notifyContactsChanged();
2796
2802
  }
2803
+ // Advance the high-water-mark so the next run only scans messages newer
2804
+ // than the highest id we just processed. Monotonic — never regress (a
2805
+ // concurrent insert mid-run keeps a higher max for the next pass).
2806
+ if (maxSeenId > startId)
2807
+ this.setKv("contacts", "seed_high_id", String(maxSeenId));
2797
2808
  return added;
2798
2809
  }
2799
2810
  /** 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.54",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",