@bobfrankston/mailx-service 0.1.47
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/ai-usage.d.ts +32 -0
- package/ai-usage.d.ts.map +1 -0
- package/ai-usage.js +98 -0
- package/ai-usage.js.map +1 -0
- package/charset.d.ts +15 -0
- package/charset.d.ts.map +1 -0
- package/charset.js +61 -0
- package/charset.js.map +1 -0
- package/db-worker-client.d.ts +32 -0
- package/db-worker-client.d.ts.map +1 -0
- package/db-worker-client.js +66 -0
- package/db-worker-client.js.map +1 -0
- package/db-worker.d.ts +39 -0
- package/db-worker.d.ts.map +1 -0
- package/db-worker.js +115 -0
- package/db-worker.js.map +1 -0
- package/google-sync.d.ts +151 -0
- package/google-sync.d.ts.map +1 -0
- package/google-sync.js +259 -0
- package/google-sync.js.map +1 -0
- package/html-to-docx.d.ts +1 -0
- package/index.d.ts +1102 -0
- package/index.d.ts.map +1 -0
- package/index.js +5476 -0
- package/index.js.map +1 -0
- package/jsonrpc.d.ts +29 -0
- package/jsonrpc.d.ts.map +1 -0
- package/jsonrpc.js +481 -0
- package/jsonrpc.js.map +1 -0
- package/local-store.d.ts +10 -0
- package/local-store.d.ts.map +1 -0
- package/local-store.js +9 -0
- package/local-store.js.map +1 -0
- package/package.json +53 -0
- package/reconciler.d.ts +90 -0
- package/reconciler.d.ts.map +1 -0
- package/reconciler.js +277 -0
- package/reconciler.js.map +1 -0
- package/sync-queue.d.ts +127 -0
- package/sync-queue.d.ts.map +1 -0
- package/sync-queue.js +271 -0
- package/sync-queue.js.map +1 -0
- package/sync-worker-client.d.ts +33 -0
- package/sync-worker-client.d.ts.map +1 -0
- package/sync-worker-client.js +89 -0
- package/sync-worker-client.js.map +1 -0
- package/sync-worker.d.ts +33 -0
- package/sync-worker.d.ts.map +1 -0
- package/sync-worker.js +143 -0
- package/sync-worker.js.map +1 -0
- package/word-html.d.ts +41 -0
- package/word-html.d.ts.map +1 -0
- package/word-html.js +122 -0
- package/word-html.js.map +1 -0
package/reconciler.d.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconciler — single background loop that drains the SyncQueue, polls
|
|
3
|
+
* for server changes, and emits state events the UI listens for.
|
|
4
|
+
*
|
|
5
|
+
* Local-first contract: the Reconciler is the ONLY code path that touches
|
|
6
|
+
* IMAP / Gmail API on behalf of UI actions. UI handlers commit locally
|
|
7
|
+
* and enqueue; the Reconciler picks up and mirrors. Reads from the UI
|
|
8
|
+
* never await the Reconciler.
|
|
9
|
+
*
|
|
10
|
+
* What this owns today:
|
|
11
|
+
* - body-fetch lane: drains `SyncQueue.nextBodyFetch()`, calls
|
|
12
|
+
* `imapManager.fetchMessageBody`, emits `bodyAvailable` / `bodyFetchError`.
|
|
13
|
+
* This is the new piece; previously body fetches happened inline on
|
|
14
|
+
* every getMessage call.
|
|
15
|
+
* - sync-state pill events: emits `syncStateChanged` so the UI can show
|
|
16
|
+
* "Sync OK / Syncing N items".
|
|
17
|
+
*
|
|
18
|
+
* What it delegates (intentional — no duplication):
|
|
19
|
+
* - message-action drain: ImapManager already runs a 30s actionsInterval
|
|
20
|
+
* that processes both outbox (processSendActions) and sync_actions
|
|
21
|
+
* (processSyncActions) per account. Per plan decision #5, outbox stays
|
|
22
|
+
* under ImapManager; we don't fork that work.
|
|
23
|
+
* - poll loops (per-account quick check, full sync, prefetch, tombstone
|
|
24
|
+
* prune): all owned by ImapManager's startPeriodicSync. The Reconciler
|
|
25
|
+
* does NOT shadow them — back-pressure on those timers can be added
|
|
26
|
+
* later via a single `setBackPressure(level)` hook.
|
|
27
|
+
*
|
|
28
|
+
* Part of docs/local-first-plan.md (step 4).
|
|
29
|
+
*/
|
|
30
|
+
import type { ImapManager } from "@bobfrankston/mailx-imap";
|
|
31
|
+
import type { MailxDB } from "@bobfrankston/mailx-store";
|
|
32
|
+
import type { SyncQueue } from "./sync-queue.js";
|
|
33
|
+
export interface ReconcilerOptions {
|
|
34
|
+
/** Max concurrent body fetches across all accounts (in addition to
|
|
35
|
+
* per-account semaphore in ImapManager). Keeps the interactive lane
|
|
36
|
+
* responsive even when prefetch is hammering. */
|
|
37
|
+
bodyFetchConcurrency?: number;
|
|
38
|
+
/** Periodic emit of syncStateChanged — drives the UI status pill. */
|
|
39
|
+
statusEmitIntervalMs?: number;
|
|
40
|
+
/** How often to fire the prefetch tick. Default 60s. */
|
|
41
|
+
prefetchIntervalMs?: number;
|
|
42
|
+
/** First prefetch fires this soon after start so the "not downloaded"
|
|
43
|
+
* dots fill in without making the user wait a minute. */
|
|
44
|
+
prefetchInitialDelayMs?: number;
|
|
45
|
+
/** Skip prefetch when the interactive body-fetch lane has at least
|
|
46
|
+
* this many items pending. Back-pressure keeps clicks responsive. */
|
|
47
|
+
prefetchBackpressureThreshold?: number;
|
|
48
|
+
/** Tombstone prune interval — bookkeeping only, hourly default. */
|
|
49
|
+
tombstonePruneIntervalMs?: number;
|
|
50
|
+
/** Tombstone retention — local-delete records older than this are
|
|
51
|
+
* removed by the periodic prune. */
|
|
52
|
+
tombstoneRetentionDays?: number;
|
|
53
|
+
}
|
|
54
|
+
export declare class Reconciler {
|
|
55
|
+
private db;
|
|
56
|
+
private imapManager;
|
|
57
|
+
private queue;
|
|
58
|
+
private opts;
|
|
59
|
+
private running;
|
|
60
|
+
private statusTimer;
|
|
61
|
+
private prefetchTimer;
|
|
62
|
+
private tombstoneTimer;
|
|
63
|
+
private inFlightFetches;
|
|
64
|
+
/** Last status payload — used to suppress no-op syncStateChanged events.
|
|
65
|
+
* During a heavy multi-folder sync the 5s tick fires a lot of identical
|
|
66
|
+
* events; the WebView's event handler runs DOM work for each, which
|
|
67
|
+
* stacks up under load. Diff-only emits keep the channel quiet. */
|
|
68
|
+
private lastStatus;
|
|
69
|
+
constructor(db: MailxDB, imapManager: ImapManager, queue: SyncQueue, opts?: ReconcilerOptions);
|
|
70
|
+
start(): void;
|
|
71
|
+
stop(): void;
|
|
72
|
+
/** Hourly bookkeeping — drop tombstones older than the retention window
|
|
73
|
+
* so the deleted-message lookup stays fast. */
|
|
74
|
+
private runTombstonePrune;
|
|
75
|
+
/** Fire prefetchBodies on each account unless the interactive body-fetch
|
|
76
|
+
* lane has user-clicked work pending — in which case skip this tick.
|
|
77
|
+
* ImapManager's per-account `prefetchingAccounts` guard de-dupes if
|
|
78
|
+
* the previous tick is still in flight. */
|
|
79
|
+
private runPrefetchTick;
|
|
80
|
+
/** User-visible "sync now" — runs an immediate poll on all accounts
|
|
81
|
+
* (or just one) without waiting for the next periodic tick. */
|
|
82
|
+
syncNow(accountId?: string): void;
|
|
83
|
+
/** Drain the body-fetch lane up to `bodyFetchConcurrency`. Each
|
|
84
|
+
* fetch is fire-and-forget; on completion it kicks the pump again
|
|
85
|
+
* so a queue that grows during work stays drained. */
|
|
86
|
+
private pumpBodyFetches;
|
|
87
|
+
/** External nudge — call after enqueueing a high-priority body fetch. */
|
|
88
|
+
kick(): void;
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=reconciler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reconciler.d.ts","sourceRoot":"","sources":["reconciler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAC;AAEzD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAEjD,MAAM,WAAW,iBAAiB;IAC9B;;sDAEkD;IAClD,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,qEAAqE;IACrE,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,wDAAwD;IACxD,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B;8DAC0D;IAC1D,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC;0EACsE;IACtE,6BAA6B,CAAC,EAAE,MAAM,CAAC;IACvC,mEAAmE;IACnE,wBAAwB,CAAC,EAAE,MAAM,CAAC;IAClC;yCACqC;IACrC,sBAAsB,CAAC,EAAE,MAAM,CAAC;CACnC;AAYD,qBAAa,UAAU;IAcf,OAAO,CAAC,EAAE;IACV,OAAO,CAAC,WAAW;IACnB,OAAO,CAAC,KAAK;IAfjB,OAAO,CAAC,IAAI,CAA8B;IAC1C,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,WAAW,CAA+C;IAClE,OAAO,CAAC,aAAa,CAA+C;IACpE,OAAO,CAAC,cAAc,CAA+C;IACrE,OAAO,CAAC,eAAe,CAAK;IAC5B;;;wEAGoE;IACpE,OAAO,CAAC,UAAU,CAAgE;gBAGtE,EAAE,EAAE,OAAO,EACX,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,SAAS,EACxB,IAAI,GAAE,iBAAsB;IAKhC,KAAK,IAAI,IAAI;IAyCb,IAAI,IAAI,IAAI;IAOZ;oDACgD;IAChD,OAAO,CAAC,iBAAiB;IAMzB;;;gDAG4C;IAC5C,OAAO,CAAC,eAAe;IAoBvB;oEACgE;IAChE,OAAO,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI;IAOjC;;2DAEuD;IACvD,OAAO,CAAC,eAAe;IAmHvB,yEAAyE;IACzE,IAAI,IAAI,IAAI;CAGf"}
|
package/reconciler.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconciler — single background loop that drains the SyncQueue, polls
|
|
3
|
+
* for server changes, and emits state events the UI listens for.
|
|
4
|
+
*
|
|
5
|
+
* Local-first contract: the Reconciler is the ONLY code path that touches
|
|
6
|
+
* IMAP / Gmail API on behalf of UI actions. UI handlers commit locally
|
|
7
|
+
* and enqueue; the Reconciler picks up and mirrors. Reads from the UI
|
|
8
|
+
* never await the Reconciler.
|
|
9
|
+
*
|
|
10
|
+
* What this owns today:
|
|
11
|
+
* - body-fetch lane: drains `SyncQueue.nextBodyFetch()`, calls
|
|
12
|
+
* `imapManager.fetchMessageBody`, emits `bodyAvailable` / `bodyFetchError`.
|
|
13
|
+
* This is the new piece; previously body fetches happened inline on
|
|
14
|
+
* every getMessage call.
|
|
15
|
+
* - sync-state pill events: emits `syncStateChanged` so the UI can show
|
|
16
|
+
* "Sync OK / Syncing N items".
|
|
17
|
+
*
|
|
18
|
+
* What it delegates (intentional — no duplication):
|
|
19
|
+
* - message-action drain: ImapManager already runs a 30s actionsInterval
|
|
20
|
+
* that processes both outbox (processSendActions) and sync_actions
|
|
21
|
+
* (processSyncActions) per account. Per plan decision #5, outbox stays
|
|
22
|
+
* under ImapManager; we don't fork that work.
|
|
23
|
+
* - poll loops (per-account quick check, full sync, prefetch, tombstone
|
|
24
|
+
* prune): all owned by ImapManager's startPeriodicSync. The Reconciler
|
|
25
|
+
* does NOT shadow them — back-pressure on those timers can be added
|
|
26
|
+
* later via a single `setBackPressure(level)` hook.
|
|
27
|
+
*
|
|
28
|
+
* Part of docs/local-first-plan.md (step 4).
|
|
29
|
+
*/
|
|
30
|
+
import { storeBus } from "@bobfrankston/mailx-store";
|
|
31
|
+
const DEFAULTS = {
|
|
32
|
+
bodyFetchConcurrency: 2,
|
|
33
|
+
statusEmitIntervalMs: 5_000,
|
|
34
|
+
prefetchIntervalMs: 60_000,
|
|
35
|
+
prefetchInitialDelayMs: 2_000,
|
|
36
|
+
prefetchBackpressureThreshold: 1,
|
|
37
|
+
tombstonePruneIntervalMs: 3600_000, // hourly
|
|
38
|
+
tombstoneRetentionDays: 30,
|
|
39
|
+
};
|
|
40
|
+
export class Reconciler {
|
|
41
|
+
db;
|
|
42
|
+
imapManager;
|
|
43
|
+
queue;
|
|
44
|
+
opts;
|
|
45
|
+
running = false;
|
|
46
|
+
statusTimer = null;
|
|
47
|
+
prefetchTimer = null;
|
|
48
|
+
tombstoneTimer = null;
|
|
49
|
+
inFlightFetches = 0;
|
|
50
|
+
/** Last status payload — used to suppress no-op syncStateChanged events.
|
|
51
|
+
* During a heavy multi-folder sync the 5s tick fires a lot of identical
|
|
52
|
+
* events; the WebView's event handler runs DOM work for each, which
|
|
53
|
+
* stacks up under load. Diff-only emits keep the channel quiet. */
|
|
54
|
+
lastStatus = { messageActions: -1, bodyFetches: -1, inFlightFetches: -1 };
|
|
55
|
+
constructor(db, imapManager, queue, opts = {}) {
|
|
56
|
+
this.db = db;
|
|
57
|
+
this.imapManager = imapManager;
|
|
58
|
+
this.queue = queue;
|
|
59
|
+
this.opts = { ...DEFAULTS, ...opts };
|
|
60
|
+
}
|
|
61
|
+
start() {
|
|
62
|
+
if (this.running)
|
|
63
|
+
return;
|
|
64
|
+
this.running = true;
|
|
65
|
+
// Status pill: emit current totals every few seconds so the UI
|
|
66
|
+
// can show "Syncing N items" without polling. Diff-only — emit
|
|
67
|
+
// ONLY when something actually changed since the last tick so
|
|
68
|
+
// we don't pipe a constant idle stream through the WebView's
|
|
69
|
+
// event channel during a heavy sync (where the WebView main
|
|
70
|
+
// thread is already busy painting list updates).
|
|
71
|
+
this.statusTimer = setInterval(() => {
|
|
72
|
+
const counts = this.queue.pendingCount();
|
|
73
|
+
const next = { ...counts, inFlightFetches: this.inFlightFetches };
|
|
74
|
+
if (next.messageActions === this.lastStatus.messageActions
|
|
75
|
+
&& next.bodyFetches === this.lastStatus.bodyFetches
|
|
76
|
+
&& next.inFlightFetches === this.lastStatus.inFlightFetches) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
this.lastStatus = next;
|
|
80
|
+
this.imapManager.emit("syncStateChanged", next);
|
|
81
|
+
}, this.opts.statusEmitIntervalMs);
|
|
82
|
+
// Periodic prefetch tick — owned here (not in ImapManager) so it
|
|
83
|
+
// can back off when the interactive body-fetch lane has work.
|
|
84
|
+
// First tick fires soon after startup so "not downloaded" dots
|
|
85
|
+
// start filling in promptly.
|
|
86
|
+
const prefetchTick = () => this.runPrefetchTick();
|
|
87
|
+
setTimeout(prefetchTick, this.opts.prefetchInitialDelayMs);
|
|
88
|
+
this.prefetchTimer = setInterval(prefetchTick, this.opts.prefetchIntervalMs);
|
|
89
|
+
// Tombstone prune — bookkeeping only. Trivial cost (one indexed
|
|
90
|
+
// DELETE per hour), kept off the IMAP-touching code path because
|
|
91
|
+
// it has nothing to do with sync.
|
|
92
|
+
const pruneTick = () => this.runTombstonePrune();
|
|
93
|
+
setTimeout(pruneTick, 30_000); // first run after startup settles
|
|
94
|
+
this.tombstoneTimer = setInterval(pruneTick, this.opts.tombstonePruneIntervalMs);
|
|
95
|
+
// Kick the body-fetch loop.
|
|
96
|
+
this.pumpBodyFetches();
|
|
97
|
+
}
|
|
98
|
+
stop() {
|
|
99
|
+
this.running = false;
|
|
100
|
+
if (this.statusTimer) {
|
|
101
|
+
clearInterval(this.statusTimer);
|
|
102
|
+
this.statusTimer = null;
|
|
103
|
+
}
|
|
104
|
+
if (this.prefetchTimer) {
|
|
105
|
+
clearInterval(this.prefetchTimer);
|
|
106
|
+
this.prefetchTimer = null;
|
|
107
|
+
}
|
|
108
|
+
if (this.tombstoneTimer) {
|
|
109
|
+
clearInterval(this.tombstoneTimer);
|
|
110
|
+
this.tombstoneTimer = null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Hourly bookkeeping — drop tombstones older than the retention window
|
|
114
|
+
* so the deleted-message lookup stays fast. */
|
|
115
|
+
runTombstonePrune() {
|
|
116
|
+
const cutoff = Date.now() - this.opts.tombstoneRetentionDays * 86400_000;
|
|
117
|
+
const n = this.db.pruneTombstones(cutoff);
|
|
118
|
+
if (n > 0)
|
|
119
|
+
console.log(` [tombstones] pruned ${n} older than ${this.opts.tombstoneRetentionDays} days`);
|
|
120
|
+
}
|
|
121
|
+
/** Fire prefetchBodies on each account unless the interactive body-fetch
|
|
122
|
+
* lane has user-clicked work pending — in which case skip this tick.
|
|
123
|
+
* ImapManager's per-account `prefetchingAccounts` guard de-dupes if
|
|
124
|
+
* the previous tick is still in flight. */
|
|
125
|
+
runPrefetchTick() {
|
|
126
|
+
if (!this.running)
|
|
127
|
+
return;
|
|
128
|
+
const counts = this.queue.pendingCount();
|
|
129
|
+
if (counts.bodyFetches >= this.opts.prefetchBackpressureThreshold) {
|
|
130
|
+
// Interactive work pending — let it drain. Next tick reconsiders.
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
for (const acct of this.db.getAccounts()) {
|
|
134
|
+
this.imapManager.prefetchBodies(acct.id).catch(e => console.error(` [prefetch] ${acct.id}: ${e?.message || e}`));
|
|
135
|
+
// Local-disk reparse runs in the same tick as prefetch — same
|
|
136
|
+
// back-pressure rules apply (interactive lane has priority).
|
|
137
|
+
// Pure local work, no IMAP / Gmail API touch.
|
|
138
|
+
this.imapManager.backfillBodyMeta(acct.id).catch(e => console.error(` [backfill] ${acct.id}: ${e?.message || e}`));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** User-visible "sync now" — runs an immediate poll on all accounts
|
|
142
|
+
* (or just one) without waiting for the next periodic tick. */
|
|
143
|
+
syncNow(accountId) {
|
|
144
|
+
const accts = accountId ? [{ id: accountId }] : this.db.getAccounts();
|
|
145
|
+
for (const a of accts) {
|
|
146
|
+
this.imapManager.processSyncActions(a.id).catch(() => { });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** Drain the body-fetch lane up to `bodyFetchConcurrency`. Each
|
|
150
|
+
* fetch is fire-and-forget; on completion it kicks the pump again
|
|
151
|
+
* so a queue that grows during work stays drained. */
|
|
152
|
+
pumpBodyFetches() {
|
|
153
|
+
if (!this.running)
|
|
154
|
+
return;
|
|
155
|
+
while (this.inFlightFetches < this.opts.bodyFetchConcurrency) {
|
|
156
|
+
const next = this.queue.nextBodyFetch();
|
|
157
|
+
if (!next)
|
|
158
|
+
return;
|
|
159
|
+
this.inFlightFetches++;
|
|
160
|
+
(async () => {
|
|
161
|
+
try {
|
|
162
|
+
// Wall-clock cap so a wedged fetchMessageBody (TCP half-
|
|
163
|
+
// open, server stalled mid-FETCH, parser blocked on a
|
|
164
|
+
// literal that never completes) can't pin inFlightFetches
|
|
165
|
+
// forever. Without this, one stuck UID drains the
|
|
166
|
+
// concurrency budget and every subsequent click sits
|
|
167
|
+
// behind it for the lifetime of the daemon. 90 s is
|
|
168
|
+
// generous (slow Dovecot reads of a 10 MB body legitimately
|
|
169
|
+
// approach 60 s) but bounds the wedge — beyond 90 s the
|
|
170
|
+
// retry path is more likely to recover than waiting on
|
|
171
|
+
// the same dead socket.
|
|
172
|
+
const FETCH_TIMEOUT_MS = 90_000;
|
|
173
|
+
let timeoutHandle;
|
|
174
|
+
// An interactive click forces the fetch past the per-message
|
|
175
|
+
// prefetch-empty backoff — the user explicitly wants THIS
|
|
176
|
+
// body now, and a background prefetch miss must not suppress
|
|
177
|
+
// it (see fetchMessageBody's `force` note, bobma 4967554).
|
|
178
|
+
const force = next.lane === "interactive";
|
|
179
|
+
const raw = await Promise.race([
|
|
180
|
+
this.imapManager.fetchMessageBody(next.accountId, next.folderId, next.uid, force),
|
|
181
|
+
new Promise((_, rej) => {
|
|
182
|
+
timeoutHandle = setTimeout(() => rej(new Error(`body fetch timeout after ${FETCH_TIMEOUT_MS / 1000}s`)), FETCH_TIMEOUT_MS);
|
|
183
|
+
}),
|
|
184
|
+
]);
|
|
185
|
+
if (timeoutHandle)
|
|
186
|
+
clearTimeout(timeoutHandle);
|
|
187
|
+
if (raw) {
|
|
188
|
+
// Look up uuid for the message topic; the body has just
|
|
189
|
+
// landed, so the DB row's body_path was updated by
|
|
190
|
+
// fetchMessageBody and the envelope is fresh.
|
|
191
|
+
const env = this.db.getMessageByUid(next.accountId, next.uid, next.folderId);
|
|
192
|
+
const msgUuid = env?.uuid;
|
|
193
|
+
storeBus.publish({
|
|
194
|
+
topic: msgUuid ? `message:${msgUuid}` : `folder:${next.folderId}`,
|
|
195
|
+
kind: "bodyAvailable",
|
|
196
|
+
accountId: next.accountId, folderId: next.folderId, uid: next.uid, msgUuid,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
// fetchMessageBody returned null without throwing.
|
|
201
|
+
// Causes seen in production: Gmail format=raw cap on
|
|
202
|
+
// big messages (~10MB), UID hash collision picking a
|
|
203
|
+
// stub, IMAP source extraction got a non-error empty
|
|
204
|
+
// response. Without an event the viewer hangs on
|
|
205
|
+
// "preview placeholder" forever — surface as a
|
|
206
|
+
// (non-transient) bodyFetchError so the message-
|
|
207
|
+
// viewer's listener can show a clear failure banner
|
|
208
|
+
// instead of a silent permanent blank.
|
|
209
|
+
const env = this.db.getMessageByUid(next.accountId, next.uid, next.folderId);
|
|
210
|
+
const msgUuid = env?.uuid;
|
|
211
|
+
storeBus.publish({
|
|
212
|
+
topic: msgUuid ? `message:${msgUuid}` : `folder:${next.folderId}`,
|
|
213
|
+
kind: "bodyFetchError",
|
|
214
|
+
accountId: next.accountId, folderId: next.folderId, uid: next.uid, msgUuid,
|
|
215
|
+
error: "body fetch returned no content (message too large for the API or stub)",
|
|
216
|
+
transient: false,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
if (err?.isNotFound) {
|
|
222
|
+
try {
|
|
223
|
+
const env = this.db.getMessageByUid(next.accountId, next.uid, next.folderId);
|
|
224
|
+
const msgUuid = env?.uuid;
|
|
225
|
+
this.db.deleteMessage(next.accountId, next.folderId, next.uid, "reconciler: server returned isNotFound", "mailx-service reconciler");
|
|
226
|
+
this.db.recalcFolderCounts(next.folderId);
|
|
227
|
+
storeBus.publish({
|
|
228
|
+
topic: msgUuid ? `message:${msgUuid}` : `folder:${next.folderId}`,
|
|
229
|
+
kind: "messageRemoved",
|
|
230
|
+
accountId: next.accountId, folderId: next.folderId, uid: next.uid, msgUuid,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
catch { /* */ }
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
const msg = err?.message || "body fetch failed";
|
|
237
|
+
// `IMAP command leaked into response stream` is the
|
|
238
|
+
// file-store's defensive rejection of a desync'd
|
|
239
|
+
// body. Treat as transient: the next fetch on a
|
|
240
|
+
// fresh client will likely produce a clean payload.
|
|
241
|
+
const transient = /connection|Too many|UNAVAILABLE|rate|429|5\d\d|timeout|ENOTFOUND|ECONNRESET|ETIMEDOUT|IMAP command leaked/i.test(msg);
|
|
242
|
+
// Transient: re-enqueue (budget=3) so the next pump
|
|
243
|
+
// tick retries. Surface the error only when the
|
|
244
|
+
// budget is exhausted — otherwise we'd flash banners
|
|
245
|
+
// for every blip on a slow server.
|
|
246
|
+
if (transient && this.queue.requeueBodyFetch(next)) {
|
|
247
|
+
// Backoff so we don't immediately re-hit the same
|
|
248
|
+
// wedge. Body-fetch lane is in-memory; setTimeout
|
|
249
|
+
// is fine.
|
|
250
|
+
const backoffMs = 500 * Math.pow(2, next.attempts);
|
|
251
|
+
setTimeout(() => this.pumpBodyFetches(), backoffMs);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
const env = this.db.getMessageByUid(next.accountId, next.uid, next.folderId);
|
|
255
|
+
const msgUuid = env?.uuid;
|
|
256
|
+
storeBus.publish({
|
|
257
|
+
topic: msgUuid ? `message:${msgUuid}` : `folder:${next.folderId}`,
|
|
258
|
+
kind: "bodyFetchError",
|
|
259
|
+
accountId: next.accountId, folderId: next.folderId, uid: next.uid, msgUuid,
|
|
260
|
+
error: msg, transient,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
this.inFlightFetches--;
|
|
267
|
+
this.pumpBodyFetches();
|
|
268
|
+
}
|
|
269
|
+
})();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** External nudge — call after enqueueing a high-priority body fetch. */
|
|
273
|
+
kick() {
|
|
274
|
+
this.pumpBodyFetches();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
//# sourceMappingURL=reconciler.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reconciler.js","sourceRoot":"","sources":["reconciler.ts"],"names":[],"mappings":"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"}
|
package/sync-queue.d.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SyncQueue — the formal enqueue surface for server-side mirroring of
|
|
3
|
+
* local actions.
|
|
4
|
+
*
|
|
5
|
+
* Local-first contract: every UI write commits to the local store first,
|
|
6
|
+
* then hands off here. Nothing in this file does network I/O; the
|
|
7
|
+
* Reconciler drains the queue.
|
|
8
|
+
*
|
|
9
|
+
* Implementation note: the underlying persistence already exists. Message
|
|
10
|
+
* ops live in `sync_actions` (drained by ImapManager.processSyncActions),
|
|
11
|
+
* non-message ops in `store_sync` (drained by the per-domain workers).
|
|
12
|
+
* This class is the public API surface — call sites switch to it so the
|
|
13
|
+
* architecture's seam is visible, the rules are enforceable, and we can
|
|
14
|
+
* later bolt on body-fetch / draft-push / send-push lanes without
|
|
15
|
+
* touching every call site again.
|
|
16
|
+
*
|
|
17
|
+
* Priority lanes (interactive > sync > prefetch > backfill) are tracked
|
|
18
|
+
* here as ordering hints; the actual scheduler is in the Reconciler.
|
|
19
|
+
*
|
|
20
|
+
* Part of docs/local-first-plan.md (steps 3-4).
|
|
21
|
+
*/
|
|
22
|
+
import type { MailxDB } from "@bobfrankston/mailx-store";
|
|
23
|
+
import type { ImapManager } from "@bobfrankston/mailx-imap";
|
|
24
|
+
export type Lane = "interactive" | "sync" | "prefetch" | "backfill";
|
|
25
|
+
/** In-memory body-fetch dedupe + ordering. We keep this as a Set rather
|
|
26
|
+
* than persisted rows because body fetches are idempotent (the file is
|
|
27
|
+
* named after the (folder, uid) pair) and a re-fetch on next click is
|
|
28
|
+
* cheap if a process crash drops the queued item. */
|
|
29
|
+
interface BodyFetchKey {
|
|
30
|
+
accountId: string;
|
|
31
|
+
folderId: number;
|
|
32
|
+
uid: number;
|
|
33
|
+
lane: Lane;
|
|
34
|
+
attempts: number;
|
|
35
|
+
}
|
|
36
|
+
export declare class SyncQueue {
|
|
37
|
+
private db;
|
|
38
|
+
private imapManager;
|
|
39
|
+
private bodyFetches;
|
|
40
|
+
/** Per-draft serialization slot. Autosave fires every ~2 s but an IMAP
|
|
41
|
+
* search-delete-append cycle takes longer; unserialized pushes
|
|
42
|
+
* overlapped, each one's "find the stale copy" running before a
|
|
43
|
+
* sibling's "append" — so neither saw the other's new copy and the
|
|
44
|
+
* Drafts folder filled with dozens of duplicates (Bob 2026-05-19).
|
|
45
|
+
* One push in flight per draft; while it runs, only the LATEST queued
|
|
46
|
+
* raw message is kept (intermediate autosaves are superseded). */
|
|
47
|
+
private draftPushes;
|
|
48
|
+
private static readonly DRAFT_PUSH_MIN_INTERVAL_MS;
|
|
49
|
+
/** Per-account debounce timers. Multiple rapid enqueues (3 flag toggles,
|
|
50
|
+
* bulk move/delete) coalesce into one processSyncActions drain after
|
|
51
|
+
* DRAIN_DEBOUNCE_MS. Was previously in ImapManager.debounceSyncActions;
|
|
52
|
+
* moved here because the queue surface is now the source of truth for
|
|
53
|
+
* "what needs draining" and it's where the rate limit belongs. */
|
|
54
|
+
private drainTimers;
|
|
55
|
+
private static readonly DRAIN_DEBOUNCE_MS;
|
|
56
|
+
constructor(db: MailxDB, imapManager: ImapManager);
|
|
57
|
+
private scheduleDrain;
|
|
58
|
+
/** Queue a server-side move. The local rows have already been moved in
|
|
59
|
+
* the DB; the mirror reaches the server on the next reconciler pass. */
|
|
60
|
+
enqueueMove(accountId: string, uid: number, fromFolderId: number, toFolderId: number): void;
|
|
61
|
+
/** Queue a server-side flag update (\Seen, \Flagged, \Answered, etc.). */
|
|
62
|
+
enqueueFlag(accountId: string, uid: number, folderId: number, flags: string[]): void;
|
|
63
|
+
/** Queue a server-side COPY (message stays in the source folder, a copy
|
|
64
|
+
* is APPENDed to the target). processSyncActions handles it via
|
|
65
|
+
* fetch+append. Unlike move, no local row is removed — the source row
|
|
66
|
+
* stays and the target copy is imported by a target-folder sync. */
|
|
67
|
+
enqueueCopy(accountId: string, uid: number, fromFolderId: number, toFolderId: number): void;
|
|
68
|
+
/** Queue a server-side delete (or trash, depending on action). */
|
|
69
|
+
enqueueDelete(accountId: string, uid: number, folderId: number, kind?: "delete" | "trash"): void;
|
|
70
|
+
/** Cancel a pending MOVE action if it hasn't drained yet. Returns true
|
|
71
|
+
* when an action was found and marked complete (effectively retracted),
|
|
72
|
+
* false when no matching action exists — caller then queues a
|
|
73
|
+
* counter-move (the server already saw the original move).
|
|
74
|
+
*
|
|
75
|
+
* Used by undelete: if the to-trash MOVE is still queued, retract it
|
|
76
|
+
* rather than queue a redundant trash→original. */
|
|
77
|
+
cancelPendingMove(accountId: string, uid: number, fromFolderId: number, targetFolderId: number): boolean;
|
|
78
|
+
/** Queue an IMAP APPEND of a draft body. Drafts already have crash
|
|
79
|
+
* recovery via the editing/.eml on disk, so this lane is in-memory
|
|
80
|
+
* fire-and-forget for now — the architectural value is centralizing
|
|
81
|
+
* the call site, not adding a second on-disk store for the same
|
|
82
|
+
* bytes. The reconciler retries on transient IMAP failure via the
|
|
83
|
+
* existing sync_actions backoff after first failure. */
|
|
84
|
+
/** 2026-09-16 — Claude Code (Fable 5.1). The UID of the copy currently on
|
|
85
|
+
* the server (and in the local Drafts row) for this draft, as far as the
|
|
86
|
+
* queue knows. The compose window's own previousDraftUid goes stale after
|
|
87
|
+
* the first save (saveDraft returns draftUid null, so it never learns the
|
|
88
|
+
* re-appended UID); the viewer's refresh matched on that stale number and
|
|
89
|
+
* the preview stopped following the draft after the first checkpoint. */
|
|
90
|
+
currentDraftUid(accountId: string, draftId: string): number | undefined;
|
|
91
|
+
enqueueDraftPush(accountId: string, rawMessage: string, previousDraftUid?: number, draftId?: string): void;
|
|
92
|
+
/** Drop the retained draft-push slot once a draft is sent or discarded, so
|
|
93
|
+
* its tracked UID doesn't linger for the compose session's lifetime. */
|
|
94
|
+
forgetDraft(accountId: string, draftId?: string): void;
|
|
95
|
+
/** The AUTHORITATIVE UID of the draft's current local/server row. The
|
|
96
|
+
* local-insert path assigns a fresh UID on every checkpoint, so the
|
|
97
|
+
* client's remembered draftUid goes stale within seconds of typing —
|
|
98
|
+
* cleanup callers MUST read this BEFORE forgetDraft(). Sending with the
|
|
99
|
+
* stale UID left the newest local Drafts row behind until the 30-min
|
|
100
|
+
* reconcile grace finally swept it (Bob 2026-07-12 "sent but still in
|
|
101
|
+
* drafts"). */
|
|
102
|
+
getDraftLastUid(accountId: string, draftId?: string): number | undefined;
|
|
103
|
+
/** Queue an outbox send. Today the outbox/<acct>/*.ltr directory IS
|
|
104
|
+
* the queue and a separate worker drains it; this seam is reserved
|
|
105
|
+
* for when that worker moves under the reconciler. */
|
|
106
|
+
enqueueSend(_accountId: string, _outboxId: string): void;
|
|
107
|
+
/** Request a body fetch. Dedupes per (account, folder, uid). The
|
|
108
|
+
* `lane` argument hints urgency: `interactive` is a click; `prefetch`
|
|
109
|
+
* is the background backfiller; `backfill` is the one-time post-sync
|
|
110
|
+
* catch-up. Reconciler picks higher priority first. */
|
|
111
|
+
enqueueBodyFetch(accountId: string, folderId: number, uid: number, lane?: Lane): void;
|
|
112
|
+
/** Re-enqueue a transient failure — the reconciler calls this after a
|
|
113
|
+
* fetch errored with a transient code. Returns `true` if the item was
|
|
114
|
+
* put back in the queue (still under retry budget) or `false` if its
|
|
115
|
+
* budget is exhausted (caller surfaces the error to the UI). */
|
|
116
|
+
requeueBodyFetch(key: BodyFetchKey, maxAttempts?: number): boolean;
|
|
117
|
+
/** Pop the next body-fetch task at or above `maxLane`. Used by the
|
|
118
|
+
* reconciler to drain interactive clicks before prefetch backfill. */
|
|
119
|
+
nextBodyFetch(maxLane?: Lane): BodyFetchKey | null;
|
|
120
|
+
/** Pending count for diagnostics + the sync-status pill. */
|
|
121
|
+
pendingCount(): {
|
|
122
|
+
messageActions: number;
|
|
123
|
+
bodyFetches: number;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export {};
|
|
127
|
+
//# sourceMappingURL=sync-queue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync-queue.d.ts","sourceRoot":"","sources":["sync-queue.ts"],"names":[],"mappings":"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"}
|