@bobfrankston/mailx-store 0.1.57 → 0.1.59
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 +7 -0
- package/db.js +70 -3
- package/package.json +5 -5
package/db.d.ts
CHANGED
|
@@ -301,6 +301,13 @@ export declare class MailxDB {
|
|
|
301
301
|
* message_folders. Omitted/false for Gmail (label = folder, many per
|
|
302
302
|
* message). Set true by the IMAP storeMessages path. */
|
|
303
303
|
exclusive?: boolean;
|
|
304
|
+
/** UIDs the server currently lists for THIS folder, when the caller
|
|
305
|
+
* just enumerated them (the set-diff backfill path). Lets move-detect
|
|
306
|
+
* distinguish a real move (old uid gone) from a DUPLICATE DELIVERY
|
|
307
|
+
* (same Message-ID at two live uids in one folder — e.g. a message
|
|
308
|
+
* addressed to two aliases of the same mailbox). Without this the
|
|
309
|
+
* single row ping-pongs between the two uids every sync cycle. */
|
|
310
|
+
liveServerUids?: Set<number>;
|
|
304
311
|
}): number;
|
|
305
312
|
/** Backfill the FTS5 `body_text` column for a message after its body
|
|
306
313
|
* has been parsed. Capped at ~64 KB of text per row — FTS5 stores the
|
package/db.js
CHANGED
|
@@ -540,6 +540,20 @@ export class MailxDB {
|
|
|
540
540
|
this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_acct_msgid ON messages(account_id, message_id)");
|
|
541
541
|
}
|
|
542
542
|
catch { /* already exists */ }
|
|
543
|
+
// GLOBAL date-order indexes for the unified-inbox streaming walk.
|
|
544
|
+
// Every folder-scoped index leads with account_id or folder_id, so a
|
|
545
|
+
// cross-folder `ORDER BY sent_date DESC` had NO usable index and
|
|
546
|
+
// SQLite sorted all ~139k inbox rows into a temp B-tree on EVERY
|
|
547
|
+
// unified page fetch — a fixed ~750ms per call (profiled 2026-07-14;
|
|
548
|
+
// user-visible as "scroll pauses at the bottom then continues", the
|
|
549
|
+
// load-more gap outrunning its 1000px lead). With these, the walk
|
|
550
|
+
// streams straight off the index: ~1ms for a 100-row page. One per
|
|
551
|
+
// date basis (sent_date default, date for the Received toggle).
|
|
552
|
+
try {
|
|
553
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_sentdate_global ON messages(sent_date DESC)");
|
|
554
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_date_global ON messages(date DESC)");
|
|
555
|
+
}
|
|
556
|
+
catch { /* already exists */ }
|
|
543
557
|
// is_replied: set when ANY other message in this account has in_reply_to
|
|
544
558
|
// pointing at this row's message_id. Primary source of truth for the ↩
|
|
545
559
|
// marker — \Answered is plan B (some servers strip it, Gmail labels
|
|
@@ -1828,7 +1842,48 @@ export class MailxDB {
|
|
|
1828
1842
|
// surface transient move-dups. Keeping the collapse for now.
|
|
1829
1843
|
if (msg.messageId) {
|
|
1830
1844
|
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);
|
|
1831
|
-
|
|
1845
|
+
// Duplicate delivery, NOT a move: same folder, different uid, and
|
|
1846
|
+
// the caller's server UID list confirms the OLD uid is still in the
|
|
1847
|
+
// mailbox. Rebinding here would ping-pong the row between the two
|
|
1848
|
+
// uids on every sync (each cycle sees the unbound uid as
|
|
1849
|
+
// "server-only", refetches it, and rebinds). Fall through to a
|
|
1850
|
+
// fresh insert instead — both copies get their own row, matching
|
|
1851
|
+
// what the server (and every other client) actually shows, and the
|
|
1852
|
+
// set-diff converges.
|
|
1853
|
+
const sameFolderDup = !!moved && moved.folder_id === msg.folderId && moved.uid !== msg.uid
|
|
1854
|
+
&& !!msg.liveServerUids?.has(moved.uid);
|
|
1855
|
+
// Cross-folder copy, NOT a move (2026-07-16). Self-addressed mail
|
|
1856
|
+
// legitimately carries ONE Message-ID in both INBOX and Sent — a
|
|
1857
|
+
// Sent backfill "moving" the INBOX row to Sent stole thousands of
|
|
1858
|
+
// INBOX rows, and the next INBOX sync saw 877 "server-only" UIDs,
|
|
1859
|
+
// refetched them, rebound them back … an infinite cross-folder
|
|
1860
|
+
// flap that hammered Dovecot, flip-flopped read/unread flags, and
|
|
1861
|
+
// starved the interactive body-fetch lane ("Fetching body from
|
|
1862
|
+
// server…" for minutes). Evidence available at this call site:
|
|
1863
|
+
// the source folder's membership row — its last_seen_at refreshes
|
|
1864
|
+
// every time that folder's sync confirms the UID is still on the
|
|
1865
|
+
// server. A recently-confirmed source instance means the message
|
|
1866
|
+
// exists in BOTH folders → insert a second row (matches server
|
|
1867
|
+
// truth). Reconcile converges either way: a real move's source
|
|
1868
|
+
// membership is dropped by the source folder's next sync and the
|
|
1869
|
+
// orphaned row is deleted. The window covers hot folders (INBOX
|
|
1870
|
+
// confirms every ~30 s); a stale or missing membership falls
|
|
1871
|
+
// through to the rebind, preserving UUID continuity for genuine
|
|
1872
|
+
// server-side moves.
|
|
1873
|
+
let crossFolderCopy = false;
|
|
1874
|
+
if (moved && !sameFolderDup && moved.folder_id !== msg.folderId) {
|
|
1875
|
+
const SOURCE_CONFIRM_WINDOW_MS = 10 * 60_000;
|
|
1876
|
+
const src = this.db.prepare("SELECT message_row_id, last_seen_at FROM message_folders WHERE folder_id = ? AND uid = ?").get(moved.folder_id, moved.uid);
|
|
1877
|
+
crossFolderCopy = !!src && src.message_row_id === moved.id
|
|
1878
|
+
&& (Date.now() - src.last_seen_at) < SOURCE_CONFIRM_WINDOW_MS;
|
|
1879
|
+
}
|
|
1880
|
+
if (sameFolderDup && moved) {
|
|
1881
|
+
console.log(` [move-detect] ${msg.accountId} ${msg.messageId}: uid ${moved.uid} still on server — duplicate copy at uid ${msg.uid}, inserting second row`);
|
|
1882
|
+
}
|
|
1883
|
+
else if (crossFolderCopy && moved) {
|
|
1884
|
+
console.log(` [move-detect] ${msg.accountId} ${msg.messageId}: source folder ${moved.folder_id}/uid ${moved.uid} recently confirmed on server — cross-folder copy at folder ${msg.folderId}/uid ${msg.uid}, inserting second row`);
|
|
1885
|
+
}
|
|
1886
|
+
else if (moved) {
|
|
1832
1887
|
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})`);
|
|
1833
1888
|
this.db.prepare("UPDATE messages SET folder_id = ?, uid = ?, cached_at = ? WHERE id = ?").run(msg.folderId, msg.uid, Date.now(), moved.id);
|
|
1834
1889
|
this.upsertMessageFolder(moved.id, msg.folderId, msg.uid, msg.exclusive);
|
|
@@ -2061,7 +2116,11 @@ export class MailxDB {
|
|
|
2061
2116
|
const totalKey = `${flaggedOnly}:${folderIds.join(",")}`;
|
|
2062
2117
|
const cachedTotal = this._unifiedTotalCache.get(totalKey);
|
|
2063
2118
|
let total;
|
|
2064
|
-
|
|
2119
|
+
// 15s TTL (was 4s): the COUNT is a ~450ms GROUP BY scan of every inbox
|
|
2120
|
+
// row, and background refreshes land often enough that a 4s TTL
|
|
2121
|
+
// recomputed it nearly every fetch. Staleness only affects the
|
|
2122
|
+
// page-count indicator / load-more end detection, never row data.
|
|
2123
|
+
if (cachedTotal && (Date.now() - cachedTotal.at) < 15_000) {
|
|
2065
2124
|
total = cachedTotal.total;
|
|
2066
2125
|
}
|
|
2067
2126
|
else {
|
|
@@ -2085,9 +2144,17 @@ export class MailxDB {
|
|
|
2085
2144
|
// idx_messages_folder_date supplies the date order. Deep pages walk more
|
|
2086
2145
|
// (bounded by offset+pageSize survivors) but remain far cheaper than a
|
|
2087
2146
|
// full window sort, and are rare in the unified inbox.
|
|
2147
|
+
// CROSS JOIN forces messages as the OUTER table so the plan drives off
|
|
2148
|
+
// idx_messages_sentdate_global / idx_messages_date_global (global
|
|
2149
|
+
// date-DESC order, no temp B-tree — the `, m.id` tiebreak only sorts
|
|
2150
|
+
// within equal-date runs) and probes message_folders per row. The
|
|
2151
|
+
// planner can't see our early `break`, so with a plain JOIN it
|
|
2152
|
+
// "optimizes" for full output: drives from mf's folder index and
|
|
2153
|
+
// sorts ALL ~139k inbox rows before yielding row 1 (~750ms/page,
|
|
2154
|
+
// profiled 2026-07-14). Forced order: ~1ms.
|
|
2088
2155
|
const baseStmt = this.db.prepare(`SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id
|
|
2089
2156
|
FROM messages m
|
|
2090
|
-
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
2157
|
+
CROSS JOIN message_folders mf ON mf.message_row_id = m.id
|
|
2091
2158
|
WHERE mf.folder_id IN (${placeholders})${flagFilter}
|
|
2092
2159
|
ORDER BY ${dateCol} DESC, m.id DESC`);
|
|
2093
2160
|
// Per-survivor enrichment (runs only for the ≤pageSize kept rows, not the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-store",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.59",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
},
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@bobfrankston/mailx-types": "^0.1.
|
|
13
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
12
|
+
"@bobfrankston/mailx-types": "^0.1.24",
|
|
13
|
+
"@bobfrankston/mailx-settings": "^0.1.35",
|
|
14
14
|
"@bobfrankston/mailx-bus": "^0.1.2",
|
|
15
15
|
"mailparser": "^3.7.2"
|
|
16
16
|
},
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
},
|
|
30
30
|
".transformedSnapshot": {
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@bobfrankston/mailx-types": "^0.1.
|
|
33
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
32
|
+
"@bobfrankston/mailx-types": "^0.1.24",
|
|
33
|
+
"@bobfrankston/mailx-settings": "^0.1.35",
|
|
34
34
|
"@bobfrankston/mailx-bus": "^0.1.2",
|
|
35
35
|
"mailparser": "^3.7.2"
|
|
36
36
|
}
|