@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.
- package/db.d.ts +1 -1
- package/db.js +49 -38
- 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
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
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 =
|
|
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 =
|
|
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
|