@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.
@@ -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