@bobfrankston/mailx-store 0.1.52 → 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 +9 -2
- package/db.js +173 -58
- 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
|
|
@@ -552,7 +559,7 @@ export declare class MailxDB {
|
|
|
552
559
|
* click, queued behind it; that IS the "loading body takes forever"
|
|
553
560
|
* bug). Now keyset-paginated by `m.id` with a `setImmediate` yield
|
|
554
561
|
* between pages, so user IPC lands in the gaps. */
|
|
555
|
-
seedContactsFromMessages(): Promise<number>;
|
|
562
|
+
seedContactsFromMessages(force?: boolean): Promise<number>;
|
|
556
563
|
/** Apply the contents of contacts.jsonc — replaces all preferred-tier rows
|
|
557
564
|
* with the entries in `preferred[]`, merges `discovered[]` into the local
|
|
558
565
|
* cache, sets the in-memory denylist, and purges any discovered rows
|
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,29 @@ 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
|
+
}
|
|
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.)
|
|
604
640
|
// One-shot cleanup: the retired insertOptimisticSentRow path wrote
|
|
605
641
|
// synthetic-negative-UID rows into Sent. Those rows are stale (the
|
|
606
642
|
// real server-synced row eventually appears with a positive UID),
|
|
@@ -1461,8 +1497,26 @@ export class MailxDB {
|
|
|
1461
1497
|
* insert / move-detect / existing-row upsert during the additive
|
|
1462
1498
|
* migration. Reconcile is the only path that DELETES from this table
|
|
1463
1499
|
* (when the server stops listing a UID in a folder). */
|
|
1464
|
-
upsertMessageFolder(messageRowId, folderId, uid) {
|
|
1500
|
+
upsertMessageFolder(messageRowId, folderId, uid, exclusive = false) {
|
|
1465
1501
|
const now = Date.now();
|
|
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);
|
|
1466
1520
|
// INSERT OR REPLACE on (folder_id, uid) — if some other message_row_id
|
|
1467
1521
|
// somehow had this slot, replace it. In practice this happens after
|
|
1468
1522
|
// an EXPUNGE+reinsert: a UID gets reused by the server for a different
|
|
@@ -1646,7 +1700,7 @@ export class MailxDB {
|
|
|
1646
1700
|
// is still in this folder. No-op if migration already populated
|
|
1647
1701
|
// it; defensive INSERT-OR-UPDATE handles the race where the row
|
|
1648
1702
|
// was created without a corresponding membership.
|
|
1649
|
-
this.upsertMessageFolder(existing.id, msg.folderId, msg.uid);
|
|
1703
|
+
this.upsertMessageFolder(existing.id, msg.folderId, msg.uid, msg.exclusive);
|
|
1650
1704
|
return existing.id;
|
|
1651
1705
|
}
|
|
1652
1706
|
// Move-detection: if this Message-ID already exists for this account
|
|
@@ -1657,26 +1711,17 @@ export class MailxDB {
|
|
|
1657
1711
|
// stacks) that point at the UUID. Only kicks in when messageId is
|
|
1658
1712
|
// present (servers usually include it; if not we fall through to a
|
|
1659
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.
|
|
1660
1719
|
if (msg.messageId) {
|
|
1661
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);
|
|
1662
1721
|
if (moved) {
|
|
1663
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})`);
|
|
1664
|
-
// Update folder_id + uid on the messages row (additive
|
|
1665
|
-
// migration: keeps the "primary location" columns valid for
|
|
1666
|
-
// existing reads). The new schema's source of truth is
|
|
1667
|
-
// message_folders — see below.
|
|
1668
1723
|
this.db.prepare("UPDATE messages SET folder_id = ?, uid = ?, cached_at = ? WHERE id = ?").run(msg.folderId, msg.uid, Date.now(), moved.id);
|
|
1669
|
-
|
|
1670
|
-
// folder + old uid) stays — reconcile in the source folder
|
|
1671
|
-
// is what eventually drops it (when the server stops
|
|
1672
|
-
// listing the UID there). For Gmail multi-label messages,
|
|
1673
|
-
// both memberships persist legitimately.
|
|
1674
|
-
this.upsertMessageFolder(moved.id, msg.folderId, msg.uid);
|
|
1675
|
-
// Notify subscribers so e.g. the reconciler can cancel any
|
|
1676
|
-
// pending deferred-delete for the original (folder, uid) —
|
|
1677
|
-
// otherwise the reconcile-delete grace timer fires for a
|
|
1678
|
-
// row that's been rebound elsewhere, and the user sees the
|
|
1679
|
-
// moved message vanish 30 minutes after the move.
|
|
1724
|
+
this.upsertMessageFolder(moved.id, msg.folderId, msg.uid, msg.exclusive);
|
|
1680
1725
|
if (this._onMoveDetected) {
|
|
1681
1726
|
try {
|
|
1682
1727
|
this._onMoveDetected({
|
|
@@ -1729,7 +1774,7 @@ export class MailxDB {
|
|
|
1729
1774
|
// source of truth — old folder_id/uid columns above are kept in
|
|
1730
1775
|
// sync during the additive migration but reads will move to JOIN
|
|
1731
1776
|
// with message_folders.
|
|
1732
|
-
this.upsertMessageFolder(rowId, msg.folderId, msg.uid);
|
|
1777
|
+
this.upsertMessageFolder(rowId, msg.folderId, msg.uid, msg.exclusive);
|
|
1733
1778
|
// Index for full-text search. body_text seeded from `msg.preview`
|
|
1734
1779
|
// here — the full parsed body isn't available at upsert time (we
|
|
1735
1780
|
// store .eml on disk; parsing is on-demand). LocalStore.getMessage
|
|
@@ -1856,6 +1901,9 @@ export class MailxDB {
|
|
|
1856
1901
|
return { items, total, page, pageSize };
|
|
1857
1902
|
}
|
|
1858
1903
|
/** Unified inbox: all inbox folders across accounts, sorted by date, paginated in SQL */
|
|
1904
|
+
/** Short-TTL cache for the unified-inbox survivor COUNT (the GROUP BY that
|
|
1905
|
+
* scans the whole inbox). Keyed by flaggedOnly+folder-set. */
|
|
1906
|
+
_unifiedTotalCache = new Map();
|
|
1859
1907
|
getUnifiedInbox(page = 1, pageSize = 50, flaggedOnly = false) {
|
|
1860
1908
|
const offset = (page - 1) * pageSize;
|
|
1861
1909
|
// Find all inbox folder IDs
|
|
@@ -1887,43 +1935,72 @@ export class MailxDB {
|
|
|
1887
1935
|
// copies the user expects to see in All Inboxes — collapsing across
|
|
1888
1936
|
// accounts hid the second one (Bob 2026-06-12). Within a single inbox,
|
|
1889
1937
|
// the to-self N-copies case still collapses as before.
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1938
|
+
// total = number of deduped survivors. The GROUP BY scans every inbox
|
|
1939
|
+
// row (~230ms on a 90k inbox) so cache it briefly — it only changes on
|
|
1940
|
+
// sync/delete, and a few seconds of staleness affects only the page-count
|
|
1941
|
+
// indicator, never the (always-live) row data (Bob 2026-06-16).
|
|
1942
|
+
const totalKey = `${flaggedOnly}:${folderIds.join(",")}`;
|
|
1943
|
+
const cachedTotal = this._unifiedTotalCache.get(totalKey);
|
|
1944
|
+
let total;
|
|
1945
|
+
if (cachedTotal && (Date.now() - cachedTotal.at) < 4000) {
|
|
1946
|
+
total = cachedTotal.total;
|
|
1947
|
+
}
|
|
1948
|
+
else {
|
|
1949
|
+
total = this.db.prepare(`SELECT COUNT(*) as cnt FROM (
|
|
1950
|
+
SELECT 1
|
|
1951
|
+
FROM messages m
|
|
1952
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
1953
|
+
WHERE mf.folder_id IN (${placeholders})${flagFilter}
|
|
1954
|
+
GROUP BY m.account_id, CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
|
|
1955
|
+
)`).get(...folderIds).cnt;
|
|
1956
|
+
this._unifiedTotalCache.set(totalKey, { total, at: Date.now() });
|
|
1957
|
+
}
|
|
1958
|
+
// STREAMING DEDUP. The old query ran ROW_NUMBER() OVER (PARTITION BY
|
|
1959
|
+
// message_id ORDER BY date) across EVERY inbox row, then took rn=1 — a
|
|
1960
|
+
// full sort of ~90k rows for a 50-row page (~780ms, Bob 2026-06-16
|
|
1961
|
+
// "getUnifiedInbox 4s/2s"). But page 1 only needs the 50 newest deduped
|
|
1962
|
+
// survivors: walk rows in date-DESC order, keep the first row seen per
|
|
1963
|
+
// (account_id, message_id) group (= the rn=1 survivor — identical rule),
|
|
1964
|
+
// and STOP once the page is filled. Typical page-1 read: ~50-100 physical
|
|
1965
|
+
// rows instead of 90k sorted. .iterate() lets us break early; the index
|
|
1966
|
+
// idx_messages_folder_date supplies the date order. Deep pages walk more
|
|
1967
|
+
// (bounded by offset+pageSize survivors) but remain far cheaper than a
|
|
1968
|
+
// full window sort, and are rare in the unified inbox.
|
|
1969
|
+
const baseStmt = this.db.prepare(`SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id
|
|
1970
|
+
FROM messages m
|
|
1971
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
1972
|
+
WHERE mf.folder_id IN (${placeholders})${flagFilter}
|
|
1973
|
+
ORDER BY m.date DESC, m.id DESC`);
|
|
1974
|
+
// Per-survivor enrichment (runs only for the ≤pageSize kept rows, not the
|
|
1975
|
+
// whole inbox). Same semantics as the old inline correlated subqueries.
|
|
1976
|
+
const pendingStmt = this.db.prepare("SELECT 1 FROM sync_actions WHERE account_id = ? AND uid = ? LIMIT 1");
|
|
1977
|
+
const dupeStmt = this.db.prepare("SELECT COUNT(DISTINCT account_id) AS c FROM messages WHERE message_id = ?");
|
|
1978
|
+
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 = ?");
|
|
1979
|
+
const seen = new Set();
|
|
1980
|
+
const kept = [];
|
|
1981
|
+
let survivorIdx = 0;
|
|
1982
|
+
const lastWanted = offset + pageSize;
|
|
1983
|
+
for (const r of baseStmt.iterate(...folderIds)) {
|
|
1984
|
+
const key = (r.message_id && r.message_id !== "")
|
|
1985
|
+
? `${r.account_id}${r.message_id}`
|
|
1986
|
+
: `${r.account_id}mid-empty:${r.id}`;
|
|
1987
|
+
if (seen.has(key))
|
|
1988
|
+
continue; // a newer copy of this group already won
|
|
1989
|
+
seen.add(key);
|
|
1990
|
+
if (survivorIdx >= offset && survivorIdx < lastWanted)
|
|
1991
|
+
kept.push(r);
|
|
1992
|
+
survivorIdx++;
|
|
1993
|
+
if (survivorIdx >= lastWanted)
|
|
1994
|
+
break; // page filled — stop walking
|
|
1995
|
+
}
|
|
1996
|
+
const rows = kept.map(r => {
|
|
1997
|
+
const hasMid = !!(r.message_id && r.message_id !== "");
|
|
1998
|
+
r.pending = pendingStmt.get(r.account_id, r.uid) ? 1 : 0;
|
|
1999
|
+
r.dupeCount = hasMid ? (dupeStmt.get(r.message_id)?.c | 0) : 0;
|
|
2000
|
+
const gr = hasMid ? groupRepliedStmt.get(r.message_id)?.r : null;
|
|
2001
|
+
r.groupReplied = (gr == null) ? r.is_replied : gr;
|
|
2002
|
+
return r;
|
|
2003
|
+
});
|
|
1927
2004
|
const items = rows.map(r => ({
|
|
1928
2005
|
id: r.id,
|
|
1929
2006
|
accountId: r.account_id,
|
|
@@ -2418,12 +2495,31 @@ export class MailxDB {
|
|
|
2418
2495
|
* Returns {busy, log, checkpointed} (log = frames in WAL, checkpointed =
|
|
2419
2496
|
* frames moved into the db) or null on error. */
|
|
2420
2497
|
checkpoint(mode = "PASSIVE") {
|
|
2498
|
+
// TRUNCATE/RESTART need the WAL write lock and otherwise WAIT up to
|
|
2499
|
+
// busy_timeout (5s on main) if a sync write is in flight — blocking the
|
|
2500
|
+
// main thread, the exact stall we are trying to avoid. Drop busy_timeout
|
|
2501
|
+
// to 0 around those modes so the checkpoint returns busy=1 INSTANTLY
|
|
2502
|
+
// instead of waiting; the next 8s tick retries. PASSIVE never waits, so
|
|
2503
|
+
// it keeps the default timeout. (PASSIVE keeps frames checkpointed but
|
|
2504
|
+
// never shrinks the WAL FILE; TRUNCATE is what reclaims the 26 MB the
|
|
2505
|
+
// file grows to during a heavy sync — Bob 2026-06-16.)
|
|
2506
|
+
const needsLock = mode === "TRUNCATE" || mode === "RESTART";
|
|
2421
2507
|
try {
|
|
2508
|
+
if (needsLock)
|
|
2509
|
+
this.db.exec("PRAGMA busy_timeout=0");
|
|
2422
2510
|
return this.db.prepare(`PRAGMA wal_checkpoint(${mode})`).get();
|
|
2423
2511
|
}
|
|
2424
2512
|
catch {
|
|
2425
2513
|
return null;
|
|
2426
2514
|
}
|
|
2515
|
+
finally {
|
|
2516
|
+
if (needsLock) {
|
|
2517
|
+
try {
|
|
2518
|
+
this.db.exec("PRAGMA busy_timeout=5000");
|
|
2519
|
+
}
|
|
2520
|
+
catch { /* ignore */ }
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2427
2523
|
}
|
|
2428
2524
|
runInTxn(fn) {
|
|
2429
2525
|
if (this.db.isTransaction)
|
|
@@ -2552,9 +2648,19 @@ export class MailxDB {
|
|
|
2552
2648
|
* click, queued behind it; that IS the "loading body takes forever"
|
|
2553
2649
|
* bug). Now keyset-paginated by `m.id` with a `setImmediate` yield
|
|
2554
2650
|
* between pages, so user IPC lands in the gaps. */
|
|
2555
|
-
async seedContactsFromMessages() {
|
|
2651
|
+
async seedContactsFromMessages(force = false) {
|
|
2556
2652
|
const VALID = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
|
|
2557
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;
|
|
2558
2664
|
const agg = new Map();
|
|
2559
2665
|
const bump = (name, address, date) => {
|
|
2560
2666
|
const email = (address || "").trim().toLowerCase();
|
|
@@ -2605,7 +2711,7 @@ export class MailxDB {
|
|
|
2605
2711
|
JOIN folders f ON m.folder_id = f.id
|
|
2606
2712
|
WHERE f.special_use = 'sent' AND m.id > ?
|
|
2607
2713
|
ORDER BY m.id LIMIT ?`);
|
|
2608
|
-
let lastId =
|
|
2714
|
+
let lastId = startId;
|
|
2609
2715
|
for (;;) {
|
|
2610
2716
|
const rows = stmt.all(lastId, PAGE);
|
|
2611
2717
|
if (rows.length === 0)
|
|
@@ -2617,6 +2723,8 @@ export class MailxDB {
|
|
|
2617
2723
|
eatRecipients(r.bcc_json, date);
|
|
2618
2724
|
}
|
|
2619
2725
|
lastId = rows[rows.length - 1].id;
|
|
2726
|
+
if (lastId > maxSeenId)
|
|
2727
|
+
maxSeenId = lastId;
|
|
2620
2728
|
if (rows.length < PAGE)
|
|
2621
2729
|
break;
|
|
2622
2730
|
await yieldLoop();
|
|
@@ -2629,7 +2737,7 @@ export class MailxDB {
|
|
|
2629
2737
|
LEFT JOIN folders f ON m.folder_id = f.id
|
|
2630
2738
|
WHERE (f.special_use IS NULL OR f.special_use != 'sent') AND m.id > ?
|
|
2631
2739
|
ORDER BY m.id LIMIT ?`);
|
|
2632
|
-
let lastId =
|
|
2740
|
+
let lastId = startId;
|
|
2633
2741
|
for (;;) {
|
|
2634
2742
|
const rows = stmt.all(lastId, PAGE);
|
|
2635
2743
|
if (rows.length === 0)
|
|
@@ -2642,6 +2750,8 @@ export class MailxDB {
|
|
|
2642
2750
|
eatRecipients(r.bcc_json, date);
|
|
2643
2751
|
}
|
|
2644
2752
|
lastId = rows[rows.length - 1].id;
|
|
2753
|
+
if (lastId > maxSeenId)
|
|
2754
|
+
maxSeenId = lastId;
|
|
2645
2755
|
if (rows.length < PAGE)
|
|
2646
2756
|
break;
|
|
2647
2757
|
await yieldLoop();
|
|
@@ -2690,6 +2800,11 @@ export class MailxDB {
|
|
|
2690
2800
|
console.log(` [contacts] seed: ${added} new + ${bumped} refreshed (discovered)`);
|
|
2691
2801
|
this.notifyContactsChanged();
|
|
2692
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));
|
|
2693
2808
|
return added;
|
|
2694
2809
|
}
|
|
2695
2810
|
/** Apply the contents of contacts.jsonc — replaces all preferred-tier rows
|