@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.
- package/db.d.ts +15 -1
- package/db.js +69 -38
- 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
|
-
|
|
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({
|
|
@@ -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 =
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
33
|
+
"@bobfrankston/mailx-settings": "^0.1.30",
|
|
34
34
|
"@bobfrankston/mailx-bus": "^0.1.2",
|
|
35
35
|
"mailparser": "^3.7.2"
|
|
36
36
|
}
|