@bobfrankston/mailx-store 0.1.58 → 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.js +59 -4
- package/package.json +5 -5
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
|
|
@@ -1836,10 +1850,39 @@ export class MailxDB {
|
|
|
1836
1850
|
// fresh insert instead — both copies get their own row, matching
|
|
1837
1851
|
// what the server (and every other client) actually shows, and the
|
|
1838
1852
|
// set-diff converges.
|
|
1839
|
-
|
|
1840
|
-
&& msg.liveServerUids?.has(moved.uid)
|
|
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) {
|
|
1841
1881
|
console.log(` [move-detect] ${msg.accountId} ${msg.messageId}: uid ${moved.uid} still on server — duplicate copy at uid ${msg.uid}, inserting second row`);
|
|
1842
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
|
+
}
|
|
1843
1886
|
else if (moved) {
|
|
1844
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})`);
|
|
1845
1888
|
this.db.prepare("UPDATE messages SET folder_id = ?, uid = ?, cached_at = ? WHERE id = ?").run(msg.folderId, msg.uid, Date.now(), moved.id);
|
|
@@ -2073,7 +2116,11 @@ export class MailxDB {
|
|
|
2073
2116
|
const totalKey = `${flaggedOnly}:${folderIds.join(",")}`;
|
|
2074
2117
|
const cachedTotal = this._unifiedTotalCache.get(totalKey);
|
|
2075
2118
|
let total;
|
|
2076
|
-
|
|
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) {
|
|
2077
2124
|
total = cachedTotal.total;
|
|
2078
2125
|
}
|
|
2079
2126
|
else {
|
|
@@ -2097,9 +2144,17 @@ export class MailxDB {
|
|
|
2097
2144
|
// idx_messages_folder_date supplies the date order. Deep pages walk more
|
|
2098
2145
|
// (bounded by offset+pageSize survivors) but remain far cheaper than a
|
|
2099
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.
|
|
2100
2155
|
const baseStmt = this.db.prepare(`SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id
|
|
2101
2156
|
FROM messages m
|
|
2102
|
-
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
2157
|
+
CROSS JOIN message_folders mf ON mf.message_row_id = m.id
|
|
2103
2158
|
WHERE mf.folder_id IN (${placeholders})${flagFilter}
|
|
2104
2159
|
ORDER BY ${dateCol} DESC, m.id DESC`);
|
|
2105
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
|
}
|