@bobfrankston/rmfmail 1.2.18 → 1.2.19
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/package.json +1 -1
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +9 -5
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +9 -5
- package/packages/mailx-store/db.d.ts +3 -0
- package/packages/mailx-store/db.d.ts.map +1 -1
- package/packages/mailx-store/db.js +88 -37
- package/packages/mailx-store/db.js.map +1 -1
- package/packages/mailx-store/db.ts +89 -43
|
@@ -2039,6 +2039,10 @@ export class MailxDB {
|
|
|
2039
2039
|
}
|
|
2040
2040
|
|
|
2041
2041
|
/** Unified inbox: all inbox folders across accounts, sorted by date, paginated in SQL */
|
|
2042
|
+
/** Short-TTL cache for the unified-inbox survivor COUNT (the GROUP BY that
|
|
2043
|
+
* scans the whole inbox). Keyed by flaggedOnly+folder-set. */
|
|
2044
|
+
private _unifiedTotalCache = new Map<string, { total: number; at: number }>();
|
|
2045
|
+
|
|
2042
2046
|
getUnifiedInbox(page = 1, pageSize = 50, flaggedOnly = false): PagedResult<MessageEnvelope> {
|
|
2043
2047
|
const offset = (page - 1) * pageSize;
|
|
2044
2048
|
// Find all inbox folder IDs
|
|
@@ -2073,48 +2077,78 @@ export class MailxDB {
|
|
|
2073
2077
|
// copies the user expects to see in All Inboxes — collapsing across
|
|
2074
2078
|
// accounts hid the second one (Bob 2026-06-12). Within a single inbox,
|
|
2075
2079
|
// the to-self N-copies case still collapses as before.
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2080
|
+
// total = number of deduped survivors. The GROUP BY scans every inbox
|
|
2081
|
+
// row (~230ms on a 90k inbox) so cache it briefly — it only changes on
|
|
2082
|
+
// sync/delete, and a few seconds of staleness affects only the page-count
|
|
2083
|
+
// indicator, never the (always-live) row data (Bob 2026-06-16).
|
|
2084
|
+
const totalKey = `${flaggedOnly}:${folderIds.join(",")}`;
|
|
2085
|
+
const cachedTotal = this._unifiedTotalCache.get(totalKey);
|
|
2086
|
+
let total: number;
|
|
2087
|
+
if (cachedTotal && (Date.now() - cachedTotal.at) < 4000) {
|
|
2088
|
+
total = cachedTotal.total;
|
|
2089
|
+
} else {
|
|
2090
|
+
total = (this.db.prepare(
|
|
2091
|
+
`SELECT COUNT(*) as cnt FROM (
|
|
2092
|
+
SELECT 1
|
|
2093
|
+
FROM messages m
|
|
2094
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
2095
|
+
WHERE mf.folder_id IN (${placeholders})${flagFilter}
|
|
2096
|
+
GROUP BY m.account_id, CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
|
|
2097
|
+
)`
|
|
2098
|
+
).get(...folderIds) as any).cnt;
|
|
2099
|
+
this._unifiedTotalCache.set(totalKey, { total, at: Date.now() });
|
|
2100
|
+
}
|
|
2085
2101
|
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2102
|
+
// STREAMING DEDUP. The old query ran ROW_NUMBER() OVER (PARTITION BY
|
|
2103
|
+
// message_id ORDER BY date) across EVERY inbox row, then took rn=1 — a
|
|
2104
|
+
// full sort of ~90k rows for a 50-row page (~780ms, Bob 2026-06-16
|
|
2105
|
+
// "getUnifiedInbox 4s/2s"). But page 1 only needs the 50 newest deduped
|
|
2106
|
+
// survivors: walk rows in date-DESC order, keep the first row seen per
|
|
2107
|
+
// (account_id, message_id) group (= the rn=1 survivor — identical rule),
|
|
2108
|
+
// and STOP once the page is filled. Typical page-1 read: ~50-100 physical
|
|
2109
|
+
// rows instead of 90k sorted. .iterate() lets us break early; the index
|
|
2110
|
+
// idx_messages_folder_date supplies the date order. Deep pages walk more
|
|
2111
|
+
// (bounded by offset+pageSize survivors) but remain far cheaper than a
|
|
2112
|
+
// full window sort, and are rare in the unified inbox.
|
|
2113
|
+
const baseStmt = this.db.prepare(
|
|
2114
|
+
`SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id
|
|
2115
|
+
FROM messages m
|
|
2116
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
2117
|
+
WHERE mf.folder_id IN (${placeholders})${flagFilter}
|
|
2118
|
+
ORDER BY m.date DESC, m.id DESC`
|
|
2119
|
+
);
|
|
2120
|
+
// Per-survivor enrichment (runs only for the ≤pageSize kept rows, not the
|
|
2121
|
+
// whole inbox). Same semantics as the old inline correlated subqueries.
|
|
2122
|
+
const pendingStmt = this.db.prepare(
|
|
2123
|
+
"SELECT 1 FROM sync_actions WHERE account_id = ? AND uid = ? LIMIT 1");
|
|
2124
|
+
const dupeStmt = this.db.prepare(
|
|
2125
|
+
"SELECT COUNT(DISTINCT account_id) AS c FROM messages WHERE message_id = ?");
|
|
2126
|
+
const groupRepliedStmt = this.db.prepare(
|
|
2127
|
+
"SELECT MAX(CASE WHEN is_replied = 1 OR flags_json LIKE '%Answered%' THEN 1 ELSE 0 END) AS r FROM messages WHERE message_id = ?");
|
|
2128
|
+
|
|
2129
|
+
const seen = new Set<string>();
|
|
2130
|
+
const kept: any[] = [];
|
|
2131
|
+
let survivorIdx = 0;
|
|
2132
|
+
const lastWanted = offset + pageSize;
|
|
2133
|
+
for (const r of baseStmt.iterate(...folderIds) as Iterable<any>) {
|
|
2134
|
+
const key = (r.message_id && r.message_id !== "")
|
|
2135
|
+
? `${r.account_id}${r.message_id}`
|
|
2136
|
+
: `${r.account_id}mid-empty:${r.id}`;
|
|
2137
|
+
if (seen.has(key)) continue; // a newer copy of this group already won
|
|
2138
|
+
seen.add(key);
|
|
2139
|
+
if (survivorIdx >= offset && survivorIdx < lastWanted) kept.push(r);
|
|
2140
|
+
survivorIdx++;
|
|
2141
|
+
if (survivorIdx >= lastWanted) break; // page filled — stop walking
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
const rows = kept.map(r => {
|
|
2145
|
+
const hasMid = !!(r.message_id && r.message_id !== "");
|
|
2146
|
+
r.pending = pendingStmt.get(r.account_id, r.uid) ? 1 : 0;
|
|
2147
|
+
r.dupeCount = hasMid ? ((dupeStmt.get(r.message_id) as any)?.c | 0) : 0;
|
|
2148
|
+
const gr = hasMid ? (groupRepliedStmt.get(r.message_id) as any)?.r : null;
|
|
2149
|
+
r.groupReplied = (gr == null) ? r.is_replied : gr;
|
|
2150
|
+
return r;
|
|
2151
|
+
});
|
|
2118
2152
|
|
|
2119
2153
|
const items: MessageEnvelope[] = rows.map(r => ({
|
|
2120
2154
|
id: r.id,
|
|
@@ -2677,8 +2711,20 @@ export class MailxDB {
|
|
|
2677
2711
|
* Returns {busy, log, checkpointed} (log = frames in WAL, checkpointed =
|
|
2678
2712
|
* frames moved into the db) or null on error. */
|
|
2679
2713
|
checkpoint(mode: "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE" = "PASSIVE"): { busy: number; log: number; checkpointed: number } | null {
|
|
2680
|
-
|
|
2681
|
-
|
|
2714
|
+
// TRUNCATE/RESTART need the WAL write lock and otherwise WAIT up to
|
|
2715
|
+
// busy_timeout (5s on main) if a sync write is in flight — blocking the
|
|
2716
|
+
// main thread, the exact stall we are trying to avoid. Drop busy_timeout
|
|
2717
|
+
// to 0 around those modes so the checkpoint returns busy=1 INSTANTLY
|
|
2718
|
+
// instead of waiting; the next 8s tick retries. PASSIVE never waits, so
|
|
2719
|
+
// it keeps the default timeout. (PASSIVE keeps frames checkpointed but
|
|
2720
|
+
// never shrinks the WAL FILE; TRUNCATE is what reclaims the 26 MB the
|
|
2721
|
+
// file grows to during a heavy sync — Bob 2026-06-16.)
|
|
2722
|
+
const needsLock = mode === "TRUNCATE" || mode === "RESTART";
|
|
2723
|
+
try {
|
|
2724
|
+
if (needsLock) this.db.exec("PRAGMA busy_timeout=0");
|
|
2725
|
+
return this.db.prepare(`PRAGMA wal_checkpoint(${mode})`).get() as any;
|
|
2726
|
+
} catch { return null; }
|
|
2727
|
+
finally { if (needsLock) { try { this.db.exec("PRAGMA busy_timeout=5000"); } catch { /* ignore */ } } }
|
|
2682
2728
|
}
|
|
2683
2729
|
|
|
2684
2730
|
runInTxn<T>(fn: () => T): T {
|