@ironflow/browser 0.26.0 → 0.28.0
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/README.md +179 -13
- package/dist/client.d.ts +94 -6
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +191 -122
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/offline.d.ts +166 -0
- package/dist/offline.d.ts.map +1 -0
- package/dist/offline.js +306 -0
- package/dist/offline.js.map +1 -0
- package/dist/queue/db.d.ts +123 -0
- package/dist/queue/db.d.ts.map +1 -0
- package/dist/queue/db.js +304 -0
- package/dist/queue/db.js.map +1 -0
- package/dist/queue/ids.d.ts +22 -0
- package/dist/queue/ids.d.ts.map +1 -0
- package/dist/queue/ids.js +45 -0
- package/dist/queue/ids.js.map +1 -0
- package/dist/queue/lock.d.ts +42 -0
- package/dist/queue/lock.d.ts.map +1 -0
- package/dist/queue/lock.js +65 -0
- package/dist/queue/lock.js.map +1 -0
- package/dist/queue/queue.d.ts +281 -0
- package/dist/queue/queue.d.ts.map +1 -0
- package/dist/queue/queue.js +894 -0
- package/dist/queue/queue.js.map +1 -0
- package/dist/queue/types.d.ts +145 -0
- package/dist/queue/types.d.ts.map +1 -0
- package/dist/queue/types.js +6 -0
- package/dist/queue/types.js.map +1 -0
- package/package.json +5 -3
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The offline write queue (ADR 0052).
|
|
3
|
+
*
|
|
4
|
+
* Write-through outbox: every eligible write is persisted BEFORE anything is
|
|
5
|
+
* attempted over the network, and a single drainer sends them in strict order.
|
|
6
|
+
*
|
|
7
|
+
* enqueue ──> serialize ──> cap check ──> IndexedDB ──> {queued, localId}
|
|
8
|
+
* │ │
|
|
9
|
+
* (throws) (QueueFullError) nothing was stored
|
|
10
|
+
*
|
|
11
|
+
* drain (under the flush lock, one tab at a time):
|
|
12
|
+
*
|
|
13
|
+
* head ─┬─ record from a newer SDK ──> state=blocked, STOP (never destroy)
|
|
14
|
+
* ├─ identity mismatch ────────> dead-letter, next
|
|
15
|
+
* ├─ older than retention ─────> dead-letter, next
|
|
16
|
+
* └─ send ─┬─ ok ──────────────> remove, notify watchers, next
|
|
17
|
+
* ├─ 401 ─────────────> release lock, onAuthRequired, retry
|
|
18
|
+
* ├─ retryable ───────> release lock, backoff, retry (head stays)
|
|
19
|
+
* └─ other 4xx ───────> dead-letter, next
|
|
20
|
+
*
|
|
21
|
+
* There is no "am I online?" check anywhere. Writes travel by bare `fetch`, and
|
|
22
|
+
* the SDK's transport is only the subscription channel, so a write-only app
|
|
23
|
+
* never opens a connection whose state could be observed. A successful send IS
|
|
24
|
+
* the online signal. Nothing listens for `online` or `visibilitychange`, so a
|
|
25
|
+
* tab that regains the network waits out the armed backoff (up to 60s) unless
|
|
26
|
+
* the app calls `flush()` itself.
|
|
27
|
+
*
|
|
28
|
+
* ponytail: five behaviours here are verified by hand, not by CI — reload
|
|
29
|
+
* survival, two-tab exclusion, real quota exhaustion, Safari eviction, and FIFO
|
|
30
|
+
* under write-through. jsdom has no IndexedDB and no Web Locks, so the suite
|
|
31
|
+
* runs on fakes. The checklist is OFFLINE-QA.md; the upgrade path is real
|
|
32
|
+
* browser mode, blocked on a workspace-wide vitest 2 -> 4 bump (TODOS.md).
|
|
33
|
+
* A green test run does NOT cover those five.
|
|
34
|
+
*/
|
|
35
|
+
import { IronflowError, QueueFullError } from "@ironflow/core";
|
|
36
|
+
import { QueueDb, QueueQuotaError } from "./db.js";
|
|
37
|
+
import { newUuid } from "./ids.js";
|
|
38
|
+
import { LOCK_BUSY, withFlushLock } from "./lock.js";
|
|
39
|
+
import { RECORD_VERSION, } from "./types.js";
|
|
40
|
+
const DEFAULTS = {
|
|
41
|
+
/** An order of magnitude above telemetry SDKs (Sentry 30, Segment 100): these are business writes. */
|
|
42
|
+
maxItems: 500,
|
|
43
|
+
maxBytes: 5 * 1024 * 1024,
|
|
44
|
+
/** Workbox's model. A write queued nine days ago is usually harmful to deliver. */
|
|
45
|
+
maxRetentionMs: 7 * 24 * 60 * 60 * 1000,
|
|
46
|
+
/** Ordering is irrelevant for writes that are already never going to be sent. */
|
|
47
|
+
maxDeadLettered: 100,
|
|
48
|
+
backoffInitialMs: 1_000,
|
|
49
|
+
backoffMaxMs: 60_000,
|
|
50
|
+
/** How long stop() lets an in-flight send finish before closing the DB. */
|
|
51
|
+
stopGraceMs: 2_000,
|
|
52
|
+
/**
|
|
53
|
+
* How long to wait for `onAuthRequired` before giving up on this attempt.
|
|
54
|
+
*
|
|
55
|
+
* Generous, because the honest case is a human reading a login form. The
|
|
56
|
+
* point is not to rush them — it is that an app callback which NEVER settles
|
|
57
|
+
* (a dismissed modal whose promise is dropped, an auth server that hangs)
|
|
58
|
+
* must not hold the drain loop open forever. See `reauthenticate`.
|
|
59
|
+
*/
|
|
60
|
+
authTimeoutMs: 5 * 60 * 1000,
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Run an application callback without letting it break the queue.
|
|
64
|
+
*
|
|
65
|
+
* A subscriber that throws during `enqueue` would otherwise reject a call whose
|
|
66
|
+
* write is ALREADY stored — the caller would reasonably retry, generating a new
|
|
67
|
+
* idempotency key for a write that is still queued, and the engine would see two
|
|
68
|
+
* distinct events.
|
|
69
|
+
*/
|
|
70
|
+
function safely(fn, value) {
|
|
71
|
+
try {
|
|
72
|
+
fn(value);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
// eslint-disable-next-line no-console
|
|
76
|
+
console.error("[ironflow] offline queue callback threw", error);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* How many times a raced counter refresh re-reads before giving up.
|
|
81
|
+
*
|
|
82
|
+
* Bounded so a queue under continuous local mutation cannot spin re-reading
|
|
83
|
+
* the whole store. Giving up is safe rather than final: the next announce asks
|
|
84
|
+
* again, and draining to empty re-derives the counters from scratch.
|
|
85
|
+
*/
|
|
86
|
+
const MAX_REFRESH_RETRIES = 5;
|
|
87
|
+
/**
|
|
88
|
+
* Classify a send failure.
|
|
89
|
+
*
|
|
90
|
+
* Reuses `IronflowError.retryable`, which the shared `send()` already sets from
|
|
91
|
+
* the HTTP status, rather than growing a second rule that drifts from the first.
|
|
92
|
+
*/
|
|
93
|
+
export function classifyFailure(error) {
|
|
94
|
+
if (!(error instanceof IronflowError)) {
|
|
95
|
+
// A thrown non-Ironflow error is a network/runtime fault: worth retrying.
|
|
96
|
+
return "retry";
|
|
97
|
+
}
|
|
98
|
+
// Key off the HTTP status. `code` is `errorBody?.code ?? HTTP_<status>`, so a
|
|
99
|
+
// proxy or a Connect handler that puts its own `code` in the body replaces it
|
|
100
|
+
// — a 401 arriving as {"code":"unauthenticated"} would miss a string match and
|
|
101
|
+
// be dead-lettered instead of pausing for re-auth. The code comparisons stay
|
|
102
|
+
// as a fallback for errors built without a status.
|
|
103
|
+
if (error.status === 401 ||
|
|
104
|
+
error.code === "HTTP_401" ||
|
|
105
|
+
error.code.toUpperCase() === "UNAUTHENTICATED") {
|
|
106
|
+
return "auth";
|
|
107
|
+
}
|
|
108
|
+
return error.retryable ? "retry" : "poison";
|
|
109
|
+
}
|
|
110
|
+
export class OfflineQueue {
|
|
111
|
+
db = null;
|
|
112
|
+
opts;
|
|
113
|
+
state = "idle";
|
|
114
|
+
pending = 0;
|
|
115
|
+
bytes = 0;
|
|
116
|
+
deadLettered = 0;
|
|
117
|
+
inFlight = 0;
|
|
118
|
+
sessionTotal = 0;
|
|
119
|
+
attempt = 0;
|
|
120
|
+
draining = false;
|
|
121
|
+
stopped = false;
|
|
122
|
+
/** A backoff retry is already armed; don't stack a second timer on top. */
|
|
123
|
+
retryArmed = false;
|
|
124
|
+
/** Cancels the armed retry. Without it, flush() would orphan the timer. */
|
|
125
|
+
cancelRetry = null;
|
|
126
|
+
/** A counter refresh is already queued; collapse a burst into one re-read. */
|
|
127
|
+
refreshQueued = false;
|
|
128
|
+
/** Counter mutations straddling an await right now. See {@link mutate}. */
|
|
129
|
+
counterMutations = 0;
|
|
130
|
+
/** Bumped by every completed counter mutation, so a racing read can tell. */
|
|
131
|
+
counterEpoch = 0;
|
|
132
|
+
/** Consecutive refreshes discarded for racing a local mutation. */
|
|
133
|
+
refreshRetries = 0;
|
|
134
|
+
/**
|
|
135
|
+
* A drain was requested while one was already running.
|
|
136
|
+
*
|
|
137
|
+
* Without this, a write enqueued in the window between the drainer reading an
|
|
138
|
+
* empty head and returning idle is stranded: its `drain()` call is dropped as
|
|
139
|
+
* a duplicate, and the idle path arms no timer to come back. Nothing would
|
|
140
|
+
* send it until the next enqueue, an explicit flush, or a page reload.
|
|
141
|
+
*/
|
|
142
|
+
drainRequested = false;
|
|
143
|
+
/** Cross-tab counter sync; null when BroadcastChannel is unavailable. */
|
|
144
|
+
channel = null;
|
|
145
|
+
statsSubs = new Set();
|
|
146
|
+
watchers = new Map();
|
|
147
|
+
constructor(options) {
|
|
148
|
+
// Object spread copies own keys even when the value is undefined, so a
|
|
149
|
+
// caller passing `maxItems: undefined` overwrites the default with
|
|
150
|
+
// undefined rather than falling back to it. `createClient` forwards every
|
|
151
|
+
// optional cap unconditionally, so that is the NORMAL path, not an edge
|
|
152
|
+
// case — left unfiltered it silently disables all four limits: the cap
|
|
153
|
+
// comparisons become `n > undefined` (always false, so the queue never
|
|
154
|
+
// fills), retention becomes `age > undefined` (nothing ever expires), and
|
|
155
|
+
// trimDeadLettered slices to NaN (the dead-letter store grows forever).
|
|
156
|
+
const provided = Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined));
|
|
157
|
+
this.opts = {
|
|
158
|
+
...DEFAULTS,
|
|
159
|
+
now: () => Date.now(),
|
|
160
|
+
schedule: (fn, ms) => {
|
|
161
|
+
const id = setTimeout(fn, ms);
|
|
162
|
+
return () => clearTimeout(id);
|
|
163
|
+
},
|
|
164
|
+
...provided,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** Open the database and hydrate the in-memory counters. */
|
|
168
|
+
async start() {
|
|
169
|
+
this.db = await QueueDb.open(this.opts.dbName);
|
|
170
|
+
await this.refreshCounters();
|
|
171
|
+
// Counters live in memory from here. Counting the store on every change
|
|
172
|
+
// would double the I/O that write-through already pays.
|
|
173
|
+
if (await this.db.hasFutureRecords()) {
|
|
174
|
+
this.setState("blocked");
|
|
175
|
+
}
|
|
176
|
+
this.openChannel();
|
|
177
|
+
this.emitStats();
|
|
178
|
+
void this.drain();
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Keep sibling tabs' counters honest.
|
|
182
|
+
*
|
|
183
|
+
* The flush lock stops two tabs draining at once, but it does nothing for
|
|
184
|
+
* their *counters*: an in-memory `pending` in tab A cannot see tab B enqueue
|
|
185
|
+
* or drain, so B's badge silently goes stale. A `BroadcastChannel` ping tells
|
|
186
|
+
* the others to re-read, which keeps the cheap in-memory counters without
|
|
187
|
+
* making them per-tab fiction.
|
|
188
|
+
*/
|
|
189
|
+
openChannel() {
|
|
190
|
+
if (typeof BroadcastChannel === "undefined")
|
|
191
|
+
return;
|
|
192
|
+
this.channel = new BroadcastChannel(`ironflow-queue:${this.opts.dbName}`);
|
|
193
|
+
this.channel.onmessage = () => {
|
|
194
|
+
// Re-read only. Never re-broadcast, or two tabs ping-pong forever.
|
|
195
|
+
//
|
|
196
|
+
// Coalesced across the WHOLE cycle, not just the microtask that starts
|
|
197
|
+
// it. The flag used to be cleared on the first line inside the microtask,
|
|
198
|
+
// before any of the awaits below had run — so every announce started its
|
|
199
|
+
// own pass. A sibling draining N writes announces once per write, and a
|
|
200
|
+
// pass costs a full-store read plus two indexed reads per live watcher,
|
|
201
|
+
// which made a burst O(N*M) rather than the O(1)-per-burst claimed here.
|
|
202
|
+
this.requestRefresh();
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Ask for a counter re-read, coalescing a burst into a single pass.
|
|
207
|
+
*
|
|
208
|
+
* Also the retry path for a read discarded by {@link counterRaced} — nothing
|
|
209
|
+
* else re-runs a refresh, so dropping one silently lets this tab under-count
|
|
210
|
+
* a sibling's writes for good and over-admit against its own cap.
|
|
211
|
+
*/
|
|
212
|
+
requestRefresh() {
|
|
213
|
+
if (this.stopped || !this.db)
|
|
214
|
+
return;
|
|
215
|
+
if (this.refreshQueued)
|
|
216
|
+
return;
|
|
217
|
+
this.refreshQueued = true;
|
|
218
|
+
queueMicrotask(() => {
|
|
219
|
+
void this.refreshCounters()
|
|
220
|
+
.then(() => this.emitStats())
|
|
221
|
+
.then(() => this.reconcileWatchers())
|
|
222
|
+
.catch((error) => {
|
|
223
|
+
// The chain is fire-and-forget, so a rejection escaping it is an
|
|
224
|
+
// unhandled rejection. stop() closing the database between two of
|
|
225
|
+
// these awaits is the ordinary way that happens, not a defensive
|
|
226
|
+
// case — reconcileWatchers reads storage once per live watcher.
|
|
227
|
+
// eslint-disable-next-line no-console
|
|
228
|
+
console.error("[ironflow] offline queue refresh failed", error);
|
|
229
|
+
})
|
|
230
|
+
.finally(() => {
|
|
231
|
+
this.refreshQueued = false;
|
|
232
|
+
// In `finally` so it still runs when the pass above failed:
|
|
233
|
+
// nothing arms a retry on the blocked path, so a tab that dropped
|
|
234
|
+
// the one announcement carrying "the future record is gone" would
|
|
235
|
+
// wait for an enqueue or a "Retry now" click to notice.
|
|
236
|
+
if (this.state === "blocked")
|
|
237
|
+
void this.drain();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Re-resolve every live watcher against storage.
|
|
243
|
+
*
|
|
244
|
+
* `notifyWatcher` runs on the drain path, so it only ever fires in the tab
|
|
245
|
+
* that did the draining. Without this, a write enqueued in tab A and
|
|
246
|
+
* delivered by tab B leaves A's watcher on its provisional "pending" forever
|
|
247
|
+
* and leaks its entry for the life of the page — and multi-tab is exactly
|
|
248
|
+
* what the channel was added for.
|
|
249
|
+
*
|
|
250
|
+
* Inherits `resolveStatus`'s v1 limit: a localId in neither store reads as
|
|
251
|
+
* "sent", which is wrong for an evicted dead letter or an origin Safari
|
|
252
|
+
* cleared. Documented under Limits in the browser SDK reference.
|
|
253
|
+
*
|
|
254
|
+
* ponytail: two indexed reads per live watcher per refresh. Fine for a UI
|
|
255
|
+
* watching a handful of rows; if an app ever watches hundreds while a
|
|
256
|
+
* sibling drains a backlog, carry the drained localIds in the broadcast
|
|
257
|
+
* message instead of re-reading storage.
|
|
258
|
+
*/
|
|
259
|
+
async reconcileWatchers() {
|
|
260
|
+
for (const localId of [...this.watchers.keys()]) {
|
|
261
|
+
const status = await this.resolveStatus(localId);
|
|
262
|
+
if (status.status === "pending")
|
|
263
|
+
continue;
|
|
264
|
+
this.notifyWatcher(localId, status);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/** Announce that this tab changed the stored state. */
|
|
268
|
+
announce() {
|
|
269
|
+
this.channel?.postMessage("changed");
|
|
270
|
+
}
|
|
271
|
+
async refreshCounters() {
|
|
272
|
+
if (!this.db)
|
|
273
|
+
return;
|
|
274
|
+
const mark = this.counterMark();
|
|
275
|
+
const usage = await this.db.usage();
|
|
276
|
+
const deadLettered = await this.db.countDeadLettered();
|
|
277
|
+
// Discard a read that raced a local counter change. This assignment is
|
|
278
|
+
// wholesale, and enqueue reserves against the cap BEFORE its await while
|
|
279
|
+
// the drain and dead-letter paths decrement AFTER theirs — so a read
|
|
280
|
+
// straddling one of those windows commits a value that predates the change
|
|
281
|
+
// the local counter already carries, leaving it permanently off by one. In
|
|
282
|
+
// the permissive direction, which lets the cap admit writes past its limit.
|
|
283
|
+
if (this.counterRaced(mark)) {
|
|
284
|
+
// Re-read instead of dropping it. Nothing else re-runs a refresh, so a
|
|
285
|
+
// discarded read leaves this tab permanently blind to whatever a sibling
|
|
286
|
+
// stored — and blind in the permissive direction, so its cap then admits
|
|
287
|
+
// writes past the limit.
|
|
288
|
+
//
|
|
289
|
+
// Re-armed on a macrotask, not a microtask: the mutation that lost us
|
|
290
|
+
// this read is parked on an IndexedDB callback, and a microtask would
|
|
291
|
+
// burn every attempt before that callback can run. A real timer rather
|
|
292
|
+
// than `opts.schedule`, which is the injectable *backoff* clock — tests
|
|
293
|
+
// hold it deliberately, and delivery must not depend on them releasing it.
|
|
294
|
+
if (this.refreshRetries < MAX_REFRESH_RETRIES) {
|
|
295
|
+
this.refreshRetries += 1;
|
|
296
|
+
setTimeout(() => this.requestRefresh(), 0);
|
|
297
|
+
}
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
this.refreshRetries = 0;
|
|
301
|
+
this.pending = usage.count;
|
|
302
|
+
this.bytes = usage.bytes;
|
|
303
|
+
this.deadLettered = deadLettered;
|
|
304
|
+
}
|
|
305
|
+
/** Snapshot taken before a store read whose result will overwrite a counter. */
|
|
306
|
+
counterMark() {
|
|
307
|
+
return { epoch: this.counterEpoch, live: this.counterMutations > 0 };
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Whether a counter mutation overlapped the read this mark was taken for —
|
|
311
|
+
* in flight at either end, or begun and finished in between.
|
|
312
|
+
*/
|
|
313
|
+
counterRaced(mark) {
|
|
314
|
+
return (mark.live || this.counterMutations > 0 || this.counterEpoch !== mark.epoch);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Mark a section whose store mutation and in-memory counter update straddle
|
|
318
|
+
* an await, so a concurrent {@link refreshCounters} knows to discard its read.
|
|
319
|
+
*/
|
|
320
|
+
async mutate(op) {
|
|
321
|
+
this.counterMutations += 1;
|
|
322
|
+
try {
|
|
323
|
+
return await op();
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
this.counterMutations -= 1;
|
|
327
|
+
this.counterEpoch += 1;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Stop draining and close the database. Safe to call mid-flight.
|
|
332
|
+
*
|
|
333
|
+
* Gives an in-flight send a bounded grace period to finish, rather than
|
|
334
|
+
* abandoning a write whose fate would then be unknown. Bounded because a send
|
|
335
|
+
* can be stuck behind the client's own request timeout, and a `close()` that
|
|
336
|
+
* blocks for 30s is its own bug — after the grace period the database closes
|
|
337
|
+
* and the drain loop's next store call fails harmlessly.
|
|
338
|
+
*/
|
|
339
|
+
async stop(stopGraceMs = this.opts.stopGraceMs) {
|
|
340
|
+
this.stopped = true;
|
|
341
|
+
// Otherwise a pending 60s backoff keeps this queue and its closures alive
|
|
342
|
+
// long after close().
|
|
343
|
+
this.clearRetry();
|
|
344
|
+
const deadline = Date.now() + stopGraceMs;
|
|
345
|
+
while (this.draining && Date.now() < deadline) {
|
|
346
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
347
|
+
}
|
|
348
|
+
this.channel?.close();
|
|
349
|
+
this.channel = null;
|
|
350
|
+
this.db?.close();
|
|
351
|
+
this.db = null;
|
|
352
|
+
}
|
|
353
|
+
// --------------------------------------------------------------------------
|
|
354
|
+
// Enqueue
|
|
355
|
+
// --------------------------------------------------------------------------
|
|
356
|
+
/**
|
|
357
|
+
* Persist a write and return its handles.
|
|
358
|
+
*
|
|
359
|
+
* Serialisation happens before the IndexedDB transaction: structured clone
|
|
360
|
+
* accepts values `JSON.stringify` later rejects (cycles, `BigInt`), and
|
|
361
|
+
* reporting "saved" for a write that can never be transmitted is worse than
|
|
362
|
+
* failing now. It also gives an exact byte size for the cap.
|
|
363
|
+
*
|
|
364
|
+
* @throws {QueueFullError} at the item cap, byte cap, or storage quota.
|
|
365
|
+
*/
|
|
366
|
+
async enqueue(input) {
|
|
367
|
+
const db = this.requireDb();
|
|
368
|
+
// JSON.stringify THROWS for cycles and BigInt and RETURNS undefined for
|
|
369
|
+
// `undefined` — the original guard only covered the second, so the common
|
|
370
|
+
// cases surfaced as a bare TypeError instead of the documented coded error.
|
|
371
|
+
let serialized;
|
|
372
|
+
try {
|
|
373
|
+
serialized = JSON.stringify(input.body);
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
throw new IronflowError(`Write is not JSON-serializable and cannot be queued: ${error instanceof Error ? error.message : String(error)}`, { code: "QUEUE_UNSERIALIZABLE", retryable: false });
|
|
377
|
+
}
|
|
378
|
+
if (serialized === undefined) {
|
|
379
|
+
throw new IronflowError("Write is not JSON-serializable and cannot be queued", { code: "QUEUE_UNSERIALIZABLE", retryable: false });
|
|
380
|
+
}
|
|
381
|
+
// Real bytes, not UTF-16 code units: a CJK or emoji body undercounts by up
|
|
382
|
+
// to 3x with .length, which would let the byte cap admit far more than it
|
|
383
|
+
// promises — on a mechanism whose whole job is staying inside a quota.
|
|
384
|
+
const bytes = new TextEncoder().encode(serialized).length;
|
|
385
|
+
if (this.pending + 1 > this.opts.maxItems) {
|
|
386
|
+
throw new QueueFullError(`Offline write queue is full (${this.opts.maxItems} writes pending)`);
|
|
387
|
+
}
|
|
388
|
+
if (this.bytes + bytes > this.opts.maxBytes) {
|
|
389
|
+
throw new QueueFullError(`Offline write queue is full (${this.opts.maxBytes} bytes)`);
|
|
390
|
+
}
|
|
391
|
+
const destination = this.opts.currentDestination();
|
|
392
|
+
const record = {
|
|
393
|
+
v: RECORD_VERSION,
|
|
394
|
+
localId: newUuid(),
|
|
395
|
+
idempotencyKey: input.idempotencyKey ?? newUuid(),
|
|
396
|
+
kind: input.kind,
|
|
397
|
+
serverUrl: destination.serverUrl,
|
|
398
|
+
environment: destination.environment,
|
|
399
|
+
body: serialized,
|
|
400
|
+
bytes,
|
|
401
|
+
enqueuedAt: this.opts.now(),
|
|
402
|
+
identity: this.opts.identity,
|
|
403
|
+
};
|
|
404
|
+
// Reserve before awaiting: two concurrent enqueues would otherwise both
|
|
405
|
+
// read the same pre-append counters and both pass a cap that only had room
|
|
406
|
+
// for one. Rolled back if the write does not land.
|
|
407
|
+
this.pending += 1;
|
|
408
|
+
this.bytes += bytes;
|
|
409
|
+
try {
|
|
410
|
+
await this.mutate(() => db.append(record));
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
this.pending -= 1;
|
|
414
|
+
this.bytes -= bytes;
|
|
415
|
+
this.emitStats();
|
|
416
|
+
if (error instanceof QueueQuotaError) {
|
|
417
|
+
throw new QueueFullError(error.message);
|
|
418
|
+
}
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
this.emitStats();
|
|
422
|
+
this.announce();
|
|
423
|
+
void this.drain();
|
|
424
|
+
return { localId: record.localId, idempotencyKey: record.idempotencyKey };
|
|
425
|
+
}
|
|
426
|
+
// --------------------------------------------------------------------------
|
|
427
|
+
// Drain
|
|
428
|
+
// --------------------------------------------------------------------------
|
|
429
|
+
/**
|
|
430
|
+
* Ask the drainer to run. Idempotent; concurrent calls collapse into one.
|
|
431
|
+
*
|
|
432
|
+
* The loop exists so a pause for re-authentication can resume *without*
|
|
433
|
+
* holding the flush lock: `drainSession` returns, the lock is released,
|
|
434
|
+
* credentials are refreshed, and only then do we go round again. Recursing
|
|
435
|
+
* from inside the session would keep the lock across a login prompt, which is
|
|
436
|
+
* exactly what other tabs must not be stuck behind.
|
|
437
|
+
*/
|
|
438
|
+
async drain() {
|
|
439
|
+
if (this.stopped || !this.db)
|
|
440
|
+
return;
|
|
441
|
+
// Deliberately NOT short-circuited on `blocked`. A cross-version window is
|
|
442
|
+
// transient — the sibling tab on the newer build drains its own records
|
|
443
|
+
// away — but an early return here made the state terminal for the life of
|
|
444
|
+
// the page: nothing re-checked, so even flush() (a "Retry now" button) did
|
|
445
|
+
// nothing. drainSession re-reads the head every time and re-blocks if the
|
|
446
|
+
// unreadable record is still there, which costs one cursor read.
|
|
447
|
+
// Already draining: record the request rather than dropping it, so a write
|
|
448
|
+
// that lands while this session is winding down still gets sent.
|
|
449
|
+
if (this.draining) {
|
|
450
|
+
this.drainRequested = true;
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
this.draining = true;
|
|
454
|
+
try {
|
|
455
|
+
// At most one re-auth per drain: if fresh credentials still get a 401,
|
|
456
|
+
// looping would hammer the login path forever.
|
|
457
|
+
let mayReauthenticate = true;
|
|
458
|
+
// Set here, not only inside drainSession: a tab that keeps losing the
|
|
459
|
+
// flush lock never enters the session and would report "20 of 0".
|
|
460
|
+
this.sessionTotal = this.pending;
|
|
461
|
+
for (;;) {
|
|
462
|
+
this.drainRequested = false;
|
|
463
|
+
const outcome = await withFlushLock(`ironflow-queue-flush:${this.opts.dbName}`, () => this.drainSession());
|
|
464
|
+
if (outcome === LOCK_BUSY) {
|
|
465
|
+
// Another tab is draining. Not an error — check back later. Report it
|
|
466
|
+
// as paused rather than idle: a stuck lock holder would otherwise let
|
|
467
|
+
// pending grow to the cap while stats() still said "idle".
|
|
468
|
+
this.setState("paused");
|
|
469
|
+
this.scheduleRetry();
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (outcome === "auth" && mayReauthenticate) {
|
|
473
|
+
mayReauthenticate = false;
|
|
474
|
+
if (await this.reauthenticate()) {
|
|
475
|
+
this.attempt = 0;
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
this.scheduleRetry();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (outcome === "auth" || outcome === "retry") {
|
|
482
|
+
this.scheduleRetry();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
// Idle, but something was enqueued while we were finishing — go again
|
|
486
|
+
// rather than leaving it stranded with no timer armed.
|
|
487
|
+
if (outcome === "idle" && this.drainRequested)
|
|
488
|
+
continue;
|
|
489
|
+
return; // idle or blocked
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
// Storage itself failed (a closed database, a blocked upgrade, private
|
|
494
|
+
// mode). Never let this escape: every call site is `void drain()`, so an
|
|
495
|
+
// uncaught rejection here is both an unhandled rejection and a queue that
|
|
496
|
+
// reports "idle" while writes sit pending with nothing armed to retry.
|
|
497
|
+
this.setState("paused");
|
|
498
|
+
this.emitStats();
|
|
499
|
+
this.scheduleRetry();
|
|
500
|
+
}
|
|
501
|
+
finally {
|
|
502
|
+
this.draining = false;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
/** Explicit flush, for a "Retry now" button or a test. */
|
|
506
|
+
async flush() {
|
|
507
|
+
// Reset the backoff so an explicit "Retry now" is immediate rather than
|
|
508
|
+
// inheriting however long the automatic schedule had grown to. Cancel the
|
|
509
|
+
// armed timer too: just dropping the flag would leave it running and let a
|
|
510
|
+
// second chain start alongside it, defeating the backoff entirely.
|
|
511
|
+
this.clearRetry();
|
|
512
|
+
this.attempt = 0;
|
|
513
|
+
await this.drain();
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Drain until the queue empties or something stops us.
|
|
517
|
+
*
|
|
518
|
+
* Returning is how the flush lock is released: a pause for re-authentication
|
|
519
|
+
* exits this function rather than awaiting inside it, so other tabs are not
|
|
520
|
+
* blocked behind a login prompt the user may never answer.
|
|
521
|
+
*/
|
|
522
|
+
async drainSession() {
|
|
523
|
+
const db = this.requireDb();
|
|
524
|
+
this.sessionTotal = this.pending;
|
|
525
|
+
for (;;) {
|
|
526
|
+
if (this.stopped)
|
|
527
|
+
return "retry";
|
|
528
|
+
const mark = this.counterMark();
|
|
529
|
+
const head = await db.head();
|
|
530
|
+
if (!head) {
|
|
531
|
+
// The one moment the counters can be re-derived rather than adjusted:
|
|
532
|
+
// the store was empty, so any accumulated drift dies here. It can
|
|
533
|
+
// accumulate — refreshCounters DISCARDS a read that raced a local
|
|
534
|
+
// mutation and nothing re-runs it, so a tab that missed a sibling's
|
|
535
|
+
// write would otherwise decrement past zero and hand its cap free room.
|
|
536
|
+
// Same race guard as that read: "empty" is a snapshot, and an enqueue
|
|
537
|
+
// reserves against the cap before its record lands.
|
|
538
|
+
if (!this.counterRaced(mark)) {
|
|
539
|
+
this.pending = 0;
|
|
540
|
+
this.bytes = 0;
|
|
541
|
+
}
|
|
542
|
+
this.sessionTotal = 0;
|
|
543
|
+
this.attempt = 0;
|
|
544
|
+
this.setState("idle");
|
|
545
|
+
return "idle";
|
|
546
|
+
}
|
|
547
|
+
// A record from a newer SDK. Leave it strictly alone and stop: skipping
|
|
548
|
+
// it would break FIFO, and dead-lettering it would destroy valid data.
|
|
549
|
+
if (head.v > RECORD_VERSION) {
|
|
550
|
+
this.setState("blocked");
|
|
551
|
+
return "blocked";
|
|
552
|
+
}
|
|
553
|
+
if (head.identity !== this.opts.identity) {
|
|
554
|
+
await this.retire(head, "identity-mismatch", "Queued under a different queueIdentity; not sent as the current user");
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (this.opts.now() - head.enqueuedAt > this.opts.maxRetentionMs) {
|
|
558
|
+
await this.retire(head, "expired", `Not delivered within ${this.opts.maxRetentionMs}ms of being queued`);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const destination = this.opts.currentDestination();
|
|
562
|
+
if (head.serverUrl !== destination.serverUrl ||
|
|
563
|
+
head.environment !== destination.environment) {
|
|
564
|
+
await this.retire(head, "destination-mismatch", `Queued for ${head.serverUrl}/${head.environment}, client now points at ` +
|
|
565
|
+
`${destination.serverUrl}/${destination.environment}`);
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
const path = this.opts.resolvePath(head.kind);
|
|
569
|
+
if (path === null) {
|
|
570
|
+
await this.retire(head, "unknown-kind", `No endpoint for queued write kind "${head.kind}"`);
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
this.setState("flushing");
|
|
574
|
+
this.inFlight = 1;
|
|
575
|
+
this.emitStats();
|
|
576
|
+
try {
|
|
577
|
+
const result = await this.opts.send(path, head);
|
|
578
|
+
await this.mutate(async () => {
|
|
579
|
+
await db.remove(head.seq);
|
|
580
|
+
this.pending -= 1;
|
|
581
|
+
this.bytes -= head.bytes;
|
|
582
|
+
});
|
|
583
|
+
this.inFlight = 0;
|
|
584
|
+
this.attempt = 0;
|
|
585
|
+
this.notifyWatcher(head.localId, { status: "sent", ...result });
|
|
586
|
+
this.emitStats();
|
|
587
|
+
this.announce();
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
catch (error) {
|
|
591
|
+
this.inFlight = 0;
|
|
592
|
+
switch (classifyFailure(error)) {
|
|
593
|
+
case "auth":
|
|
594
|
+
this.setState("paused");
|
|
595
|
+
this.emitStats();
|
|
596
|
+
// Returning releases the flush lock; drain() refreshes and resumes.
|
|
597
|
+
return "auth";
|
|
598
|
+
case "retry":
|
|
599
|
+
this.setState("paused");
|
|
600
|
+
this.emitStats();
|
|
601
|
+
return "retry";
|
|
602
|
+
case "poison":
|
|
603
|
+
await this.retire(head, "rejected", error instanceof Error ? error.message : String(error));
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
/** Move a write to the dead-letter store and tell everyone who cares. */
|
|
610
|
+
async retire(write, reason, message) {
|
|
611
|
+
const db = this.requireDb();
|
|
612
|
+
await this.mutate(async () => {
|
|
613
|
+
await db.deadLetter({
|
|
614
|
+
write,
|
|
615
|
+
reason,
|
|
616
|
+
message,
|
|
617
|
+
deadLetteredAt: this.opts.now(),
|
|
618
|
+
});
|
|
619
|
+
// Only scan when the cap could actually be exceeded: trimming reads the
|
|
620
|
+
// whole dead-letter store, and retiring runs back-to-back on the
|
|
621
|
+
// identity-mismatch and expiry paths.
|
|
622
|
+
if (this.deadLettered + 1 > this.opts.maxDeadLettered) {
|
|
623
|
+
await db.trimDeadLettered(this.opts.maxDeadLettered);
|
|
624
|
+
}
|
|
625
|
+
this.pending -= 1;
|
|
626
|
+
this.bytes -= write.bytes;
|
|
627
|
+
this.deadLettered = await db.countDeadLettered();
|
|
628
|
+
});
|
|
629
|
+
this.notifyWatcher(write.localId, { status: "lost", reason, message });
|
|
630
|
+
if (this.opts.onWriteLost) {
|
|
631
|
+
safely(() => this.opts.onWriteLost(write, reason, message), undefined);
|
|
632
|
+
}
|
|
633
|
+
this.emitStats();
|
|
634
|
+
this.announce();
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Obtain fresh credentials.
|
|
638
|
+
*
|
|
639
|
+
* If the caller reports a DIFFERENT principal — the natural outcome when
|
|
640
|
+
* `onAuthRequired` shows a login prompt on a shared machine and someone else
|
|
641
|
+
* signs in — the queue rebinds to that identity. The pending records then fail
|
|
642
|
+
* the identity check and are quarantined instead of being transmitted under
|
|
643
|
+
* the new user's credentials.
|
|
644
|
+
*
|
|
645
|
+
* Bounded by `authTimeoutMs`. `drain()` awaits this while `draining` is true,
|
|
646
|
+
* so a callback that never settles never reaches the `finally` that clears
|
|
647
|
+
* the flag — and every later `drain()` and `flush()` then short-circuits on
|
|
648
|
+
* it. Delivery would stop for the life of the page with no in-app recovery,
|
|
649
|
+
* while `pending` climbed to `maxItems` and started throwing `QueueFullError`
|
|
650
|
+
* at the app. A dropped promise is the normal shape of a dismissed login
|
|
651
|
+
* modal, so this is an ordinary path, not a defensive one.
|
|
652
|
+
*
|
|
653
|
+
* Timing out returns false, which is the existing "no credentials obtained"
|
|
654
|
+
* branch: the caller backs off and tries again. Nothing is dead-lettered — a
|
|
655
|
+
* later login still delivers these writes.
|
|
656
|
+
*
|
|
657
|
+
* @returns whether fresh credentials were obtained.
|
|
658
|
+
*/
|
|
659
|
+
async reauthenticate() {
|
|
660
|
+
// Nothing can refresh the credential; back off and try again rather than
|
|
661
|
+
// dead-lettering writes that a later login would deliver fine.
|
|
662
|
+
if (!this.opts.onAuthRequired)
|
|
663
|
+
return false;
|
|
664
|
+
// Held on an object rather than a bare `let`: the assignment happens inside
|
|
665
|
+
// a callback, which control-flow analysis cannot see, so a plain variable
|
|
666
|
+
// narrows to `undefined` and the cancel below stops typechecking.
|
|
667
|
+
const timeout = {};
|
|
668
|
+
try {
|
|
669
|
+
const result = await Promise.race([
|
|
670
|
+
this.opts.onAuthRequired(),
|
|
671
|
+
new Promise((_resolve, reject) => {
|
|
672
|
+
const cancel = this.opts.schedule(() => reject(new Error("onAuthRequired did not settle in time")), this.opts.authTimeoutMs);
|
|
673
|
+
if (typeof cancel === "function")
|
|
674
|
+
timeout.cancel = cancel;
|
|
675
|
+
}),
|
|
676
|
+
]);
|
|
677
|
+
if (result && result.identity && result.identity !== this.opts.identity) {
|
|
678
|
+
this.opts.identity = result.identity;
|
|
679
|
+
}
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
finally {
|
|
686
|
+
// The callback usually wins the race, and an armed 5-minute timer would
|
|
687
|
+
// otherwise keep this queue and its closures alive long past close().
|
|
688
|
+
timeout.cancel?.();
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Exponential backoff with full jitter.
|
|
693
|
+
*
|
|
694
|
+
* Jitter matters more than usual here: every tab that came back online at the
|
|
695
|
+
* same moment would otherwise retry in lockstep and manufacture the 429 the
|
|
696
|
+
* queue is trying to survive.
|
|
697
|
+
*/
|
|
698
|
+
scheduleRetry() {
|
|
699
|
+
if (this.stopped || this.retryArmed)
|
|
700
|
+
return;
|
|
701
|
+
const ceiling = Math.min(this.opts.backoffMaxMs, this.opts.backoffInitialMs * 2 ** this.attempt);
|
|
702
|
+
this.attempt += 1;
|
|
703
|
+
const delay = Math.random() * ceiling;
|
|
704
|
+
this.retryArmed = true;
|
|
705
|
+
const cancel = this.opts.schedule(() => {
|
|
706
|
+
this.retryArmed = false;
|
|
707
|
+
this.cancelRetry = null;
|
|
708
|
+
void this.drain();
|
|
709
|
+
}, delay);
|
|
710
|
+
this.cancelRetry = typeof cancel === "function" ? cancel : null;
|
|
711
|
+
}
|
|
712
|
+
/** Drop a pending retry so a new one does not run alongside it. */
|
|
713
|
+
clearRetry() {
|
|
714
|
+
this.cancelRetry?.();
|
|
715
|
+
this.cancelRetry = null;
|
|
716
|
+
this.retryArmed = false;
|
|
717
|
+
}
|
|
718
|
+
// --------------------------------------------------------------------------
|
|
719
|
+
// Dead letter surface
|
|
720
|
+
// --------------------------------------------------------------------------
|
|
721
|
+
/** Every write that will never be sent, oldest first. */
|
|
722
|
+
async deadLetter() {
|
|
723
|
+
return this.requireDb().listDeadLettered();
|
|
724
|
+
}
|
|
725
|
+
/** Forget a dead-lettered write. Returns false if it was already gone. */
|
|
726
|
+
async discard(localId) {
|
|
727
|
+
const db = this.requireDb();
|
|
728
|
+
const removed = await this.mutate(async () => {
|
|
729
|
+
const gone = await db.discardDeadLettered(localId);
|
|
730
|
+
if (gone)
|
|
731
|
+
this.deadLettered = await db.countDeadLettered();
|
|
732
|
+
return gone;
|
|
733
|
+
});
|
|
734
|
+
if (removed) {
|
|
735
|
+
this.emitStats();
|
|
736
|
+
this.announce();
|
|
737
|
+
}
|
|
738
|
+
return removed;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Put a dead-lettered write back on the queue.
|
|
742
|
+
*
|
|
743
|
+
* It goes to the BACK, with a fresh `seq` — re-inserting at its original
|
|
744
|
+
* position would mean rewriting keys, and everything that was behind it has
|
|
745
|
+
* already been delivered. The idempotency key is preserved so a replay of a
|
|
746
|
+
* write that did land is deduplicated by the engine.
|
|
747
|
+
*/
|
|
748
|
+
async retry(localId) {
|
|
749
|
+
const db = this.requireDb();
|
|
750
|
+
// Refuse a record a NEWER build dead-lettered. `trimDeadLettered`
|
|
751
|
+
// deliberately keeps these alive because this build cannot read them —
|
|
752
|
+
// requeueing one would move it to the head of our own outbox, where it
|
|
753
|
+
// blocks delivery of everything behind it until the newer build returns.
|
|
754
|
+
const entry = await db.getDeadLettered(localId);
|
|
755
|
+
if (!entry)
|
|
756
|
+
return false;
|
|
757
|
+
if (entry.write.v > RECORD_VERSION)
|
|
758
|
+
return false;
|
|
759
|
+
const write = await this.mutate(async () => {
|
|
760
|
+
const requeued = await db.requeueDeadLettered(localId, this.opts.now());
|
|
761
|
+
if (!requeued)
|
|
762
|
+
return undefined;
|
|
763
|
+
this.pending += 1;
|
|
764
|
+
this.bytes += requeued.bytes;
|
|
765
|
+
this.deadLettered = await db.countDeadLettered();
|
|
766
|
+
return requeued;
|
|
767
|
+
});
|
|
768
|
+
if (!write)
|
|
769
|
+
return false;
|
|
770
|
+
this.emitStats();
|
|
771
|
+
this.announce();
|
|
772
|
+
void this.drain();
|
|
773
|
+
return true;
|
|
774
|
+
}
|
|
775
|
+
// --------------------------------------------------------------------------
|
|
776
|
+
// Observability
|
|
777
|
+
// --------------------------------------------------------------------------
|
|
778
|
+
stats() {
|
|
779
|
+
return {
|
|
780
|
+
pending: this.pending,
|
|
781
|
+
inFlight: this.inFlight,
|
|
782
|
+
total: this.sessionTotal,
|
|
783
|
+
deadLettered: this.deadLettered,
|
|
784
|
+
state: this.state,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
/** Subscribe to queue-wide stats. Returns an unsubscribe function. */
|
|
788
|
+
subscribe(cb) {
|
|
789
|
+
this.statsSubs.add(cb);
|
|
790
|
+
// Isolated like every other emission: a subscriber that throws on its very
|
|
791
|
+
// first snapshot must not take down whatever was registering it.
|
|
792
|
+
safely(cb, this.stats());
|
|
793
|
+
return () => this.statsSubs.delete(cb);
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Watch one write by its `localId`.
|
|
797
|
+
*
|
|
798
|
+
* The `pending: true` on an enqueue result is a one-shot value; without this
|
|
799
|
+
* there is no way to learn that a specific row landed. Firestore's
|
|
800
|
+
* `hasPendingWrites` is a live flag, and copying the shape without the
|
|
801
|
+
* liveness would have been a dead end.
|
|
802
|
+
*/
|
|
803
|
+
watch(localId, cb) {
|
|
804
|
+
let set = this.watchers.get(localId);
|
|
805
|
+
if (!set) {
|
|
806
|
+
set = new Set();
|
|
807
|
+
this.watchers.set(localId, set);
|
|
808
|
+
}
|
|
809
|
+
set.add(cb);
|
|
810
|
+
// Immediate provisional value, so a UI has something to render this tick.
|
|
811
|
+
safely(cb, { status: "pending" });
|
|
812
|
+
// Then reconcile against storage. After a reload — the exact case the docs
|
|
813
|
+
// point people here for — the write may already have landed or been
|
|
814
|
+
// dead-lettered, and a bare "pending" would leave that row spinning forever.
|
|
815
|
+
// Skipped if a live notification beat us to it: notifyWatcher drops the
|
|
816
|
+
// watcher on a terminal status, so an absent entry means it already fired
|
|
817
|
+
// (and its value is richer than anything storage can reconstruct, since the
|
|
818
|
+
// delivered record is gone by then).
|
|
819
|
+
//
|
|
820
|
+
// Delivered through notifyWatcher so EVERY callback on this localId hears
|
|
821
|
+
// the terminal status. Firing only `cb` while deleting the whole map entry
|
|
822
|
+
// left a sibling registered in the same tick — two components watching one
|
|
823
|
+
// write after a reload — failing the guard below and spinning forever.
|
|
824
|
+
void this.resolveStatus(localId).then((status) => {
|
|
825
|
+
if (status.status === "pending")
|
|
826
|
+
return;
|
|
827
|
+
if (!this.watchers.get(localId)?.has(cb))
|
|
828
|
+
return;
|
|
829
|
+
this.notifyWatcher(localId, status);
|
|
830
|
+
});
|
|
831
|
+
return () => {
|
|
832
|
+
set.delete(cb);
|
|
833
|
+
// Identity-checked: notifyWatcher deletes the entry on a terminal status,
|
|
834
|
+
// so a later watch() on the same localId holds a DIFFERENT Set. Deleting
|
|
835
|
+
// by key alone would evict that live subscriber.
|
|
836
|
+
if (set.size === 0 && this.watchers.get(localId) === set) {
|
|
837
|
+
this.watchers.delete(localId);
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Current status of one write, derived from what is actually stored.
|
|
843
|
+
*
|
|
844
|
+
* One transaction over both stores, not two reads: `deadLetter()` moves a
|
|
845
|
+
* record between them atomically, so separate reads can miss it in both and
|
|
846
|
+
* report a LOST write as delivered — the worst answer this surface can give.
|
|
847
|
+
*/
|
|
848
|
+
async resolveStatus(localId) {
|
|
849
|
+
if (!this.db)
|
|
850
|
+
return { status: "pending" };
|
|
851
|
+
const found = await this.db.statusOf(localId);
|
|
852
|
+
if (found.kind === "dead") {
|
|
853
|
+
return {
|
|
854
|
+
status: "lost",
|
|
855
|
+
reason: found.entry.reason,
|
|
856
|
+
message: found.entry.message,
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
if (found.kind === "queued")
|
|
860
|
+
return { status: "pending" };
|
|
861
|
+
// In neither store: it was delivered and removed.
|
|
862
|
+
return { status: "sent" };
|
|
863
|
+
}
|
|
864
|
+
notifyWatcher(localId, status) {
|
|
865
|
+
const subs = this.watchers.get(localId);
|
|
866
|
+
if (!subs)
|
|
867
|
+
return;
|
|
868
|
+
for (const cb of subs)
|
|
869
|
+
safely(cb, status);
|
|
870
|
+
// Terminal state — nothing further will ever be reported for this write.
|
|
871
|
+
this.watchers.delete(localId);
|
|
872
|
+
}
|
|
873
|
+
setState(next) {
|
|
874
|
+
if (this.state === next)
|
|
875
|
+
return;
|
|
876
|
+
this.state = next;
|
|
877
|
+
this.emitStats();
|
|
878
|
+
}
|
|
879
|
+
emitStats() {
|
|
880
|
+
const snapshot = this.stats();
|
|
881
|
+
for (const cb of this.statsSubs)
|
|
882
|
+
safely(cb, snapshot);
|
|
883
|
+
}
|
|
884
|
+
requireDb() {
|
|
885
|
+
if (!this.db) {
|
|
886
|
+
throw new IronflowError("Offline queue is not started", {
|
|
887
|
+
code: "QUEUE_NOT_STARTED",
|
|
888
|
+
retryable: false,
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
return this.db;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
//# sourceMappingURL=queue.js.map
|