@gonvex/client 0.5.2-staging.14 → 0.5.2-staging.15

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/dist/outbox.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { OptimisticPatch } from "./optimistic.js";
2
2
  import type { LocalExecution } from "@gonvex/local-runtime";
3
+ import type { ReducerErrorClass } from "@gonvex/protocol";
4
+ export type { ReducerErrorClass };
3
5
  export type ReducerOutboxOptions = {
4
6
  databaseName?: string;
5
7
  indexedDB?: IDBFactory;
@@ -52,11 +54,53 @@ export type ReducerOutboxEntry = {
52
54
  patches?: OptimisticPatch[];
53
55
  localExecution?: LocalExecution;
54
56
  createdAt: number;
57
+ /** Failed deliveries that count toward the retry budget. */
55
58
  attempts: number;
56
59
  nextAttemptAt: number;
57
60
  lastError?: string;
58
- state: "pending" | "inflight" | "committed";
61
+ /** Classification of `lastError`; absent for entries written by older SDKs. */
62
+ lastErrorClass?: OutboxErrorClass;
63
+ /** When the entry entered its current `failed` or `rejected` state. */
64
+ settledAt?: number;
65
+ state: ReducerOutboxState;
59
66
  };
67
+ /**
68
+ * Lifecycle of one durable intent.
69
+ *
70
+ * - `pending`: waiting to be sent (possibly backing off after a failure).
71
+ * - `inflight`: sent, waiting for the server's verdict.
72
+ * - `committed`: the server accepted it; retained until reconciliation.
73
+ * - `failed`: transient delivery failures exhausted the retry budget. The
74
+ * intent and its optimistic prediction are kept until the app retries or
75
+ * discards it. A failed entry no longer blocks later intents.
76
+ * - `rejected`: the server permanently refused it. Its prediction was rolled
77
+ * back; the record stays so a UI can explain what happened until the app
78
+ * dismisses (discards) or retries it.
79
+ */
80
+ export type ReducerOutboxState = "pending" | "inflight" | "committed" | "failed" | "rejected";
81
+ /** Server classification, plus `network` for a dropped connection with no verdict. */
82
+ export type OutboxErrorClass = ReducerErrorClass | "network";
83
+ export type OutboxFailureOptions = {
84
+ errorClass?: OutboxErrorClass;
85
+ /** False keeps `attempts` unchanged (connectivity loss, re-authentication). */
86
+ countAttempt?: boolean;
87
+ /** Park as `failed` once counted attempts reach this many. */
88
+ maxAttempts?: number;
89
+ /** Cap for exponential backoff. Default 30s. */
90
+ maxBackoffMs?: number;
91
+ /** Explicit retry delay instead of exponential backoff. */
92
+ delayMs?: number;
93
+ };
94
+ export type ReducerOutboxScopeSummary = {
95
+ scope: string;
96
+ /** Every entry for the scope, including failed and rejected records. */
97
+ count: number;
98
+ oldestCreatedAt: number;
99
+ };
100
+ /** Entries whose optimistic prediction is still part of the local overlay. */
101
+ export declare function outboxEntryIsLive(entry: Pick<ReducerOutboxEntry, "state">): boolean;
102
+ /** Entries that still wait for delivery without user action. */
103
+ export declare function outboxEntryIsQueued(entry: Pick<ReducerOutboxEntry, "state">): boolean;
60
104
  export type EnqueueReducer = {
61
105
  scope: string;
62
106
  path: string;
@@ -76,14 +120,27 @@ export type ReducerOutbox = {
76
120
  list(scope: string): Promise<ReducerOutboxEntry[]>;
77
121
  updateLocal(id: number, patches: OptimisticPatch[], execution: LocalExecution): Promise<void>;
78
122
  nextReady(scope: string, now: number): Promise<ReducerOutboxEntry | undefined>;
79
- markInflight(id: number): Promise<void>;
123
+ /** Resolves true when the entry is inflight; failed/rejected/missing entries are never revived. */
124
+ markInflight(id: number): Promise<boolean | void>;
80
125
  /** Return a just-admitted entry to pending without recording a failed attempt. */
81
126
  markPending(id: number): Promise<void>;
82
127
  markCommitted(id: number): Promise<void>;
83
128
  ack(id: number): Promise<void>;
84
- fail(id: number, error: string): Promise<void>;
129
+ /** Record a failed delivery: back off, or park as `failed` once the budget is spent. */
130
+ fail(id: number, error: string, options?: OutboxFailureOptions): Promise<ReducerOutboxEntry | undefined | void>;
131
+ /** Record a permanent server rejection durably instead of deleting the entry. */
132
+ reject(id: number, error: string): Promise<ReducerOutboxEntry | undefined>;
133
+ /** Re-arm a failed or rejected entry with a fresh retry budget. */
134
+ retry(id: number): Promise<ReducerOutboxEntry | undefined>;
135
+ /** Atomically delete an entry only while it is in one of `states`. */
136
+ discard(id: number, states: readonly ReducerOutboxState[]): Promise<ReducerOutboxEntry | undefined>;
137
+ /** Owners with durable entries, including identities that never signed back in. */
138
+ listScopes(): Promise<ReducerOutboxScopeSummary[]>;
139
+ /** Entries still queued for delivery (excludes failed and rejected records). */
85
140
  count(scope: string): Promise<number>;
86
141
  clear(scope: string): Promise<void>;
142
+ /** Delete every durable entry for a scope that is not currently active. */
143
+ purgeScope(scope: string): Promise<number>;
87
144
  subscribe(listener: () => void): () => void;
88
145
  };
89
146
  /**
@@ -115,21 +172,30 @@ export declare class DexieReducerOutbox implements ReducerOutbox {
115
172
  list(scope: string): Promise<ReducerOutboxEntry[]>;
116
173
  updateLocal(id: number, patches: OptimisticPatch[], execution: LocalExecution): Promise<void>;
117
174
  nextReady(scope: string, now: number): Promise<ReducerOutboxEntry | undefined>;
118
- markInflight(id: number): Promise<void>;
175
+ markInflight(id: number): Promise<boolean>;
119
176
  markPending(id: number): Promise<void>;
120
177
  markCommitted(id: number): Promise<void>;
121
178
  ack(id: number): Promise<void>;
122
- fail(id: number, error: string): Promise<void>;
179
+ fail(id: number, error: string, options?: OutboxFailureOptions): Promise<ReducerOutboxEntry | undefined>;
180
+ reject(id: number, error: string): Promise<ReducerOutboxEntry | undefined>;
181
+ retry(id: number): Promise<ReducerOutboxEntry | undefined>;
182
+ discard(id: number, states: readonly ReducerOutboxState[]): Promise<ReducerOutboxEntry | undefined>;
183
+ listScopes(): Promise<ReducerOutboxScopeSummary[]>;
123
184
  count(scope: string): Promise<number>;
124
185
  clear(scope: string): Promise<void>;
186
+ purgeScope(scope: string): Promise<number>;
125
187
  subscribe(listener: () => void): () => void;
126
188
  private enqueueInMemory;
127
189
  private loadAllFromMemory;
128
- private markInflightInMemory;
129
190
  private markPendingInMemory;
130
191
  private markCommittedInMemory;
131
192
  private ackInMemory;
132
- private failInMemory;
193
+ /**
194
+ * Apply one atomic read-modify-write. `change` returns the replacement,
195
+ * `null` to delete the entry, or `undefined` to leave it untouched.
196
+ */
197
+ private transition;
198
+ private transitionInMemory;
133
199
  private sortedMemoryEntries;
134
200
  private remember;
135
201
  private replaceMemoryEntriesForScope;
@@ -164,16 +230,29 @@ export declare class StoreReducerOutbox implements ReducerOutbox {
164
230
  list(scope: string): Promise<ReducerOutboxEntry[]>;
165
231
  updateLocal(id: number, patches: OptimisticPatch[], execution: LocalExecution): Promise<void>;
166
232
  nextReady(scope: string, now: number): Promise<ReducerOutboxEntry | undefined>;
167
- markInflight(id: number): Promise<void>;
233
+ markInflight(id: number): Promise<boolean>;
168
234
  markPending(id: number): Promise<void>;
169
235
  markCommitted(id: number): Promise<void>;
170
236
  ack(id: number): Promise<void>;
171
- fail(id: number, error: string): Promise<void>;
237
+ fail(id: number, error: string, options?: OutboxFailureOptions): Promise<ReducerOutboxEntry | undefined>;
238
+ reject(id: number, error: string): Promise<ReducerOutboxEntry | undefined>;
239
+ retry(id: number): Promise<ReducerOutboxEntry | undefined>;
240
+ discard(id: number, states: readonly ReducerOutboxState[]): Promise<ReducerOutboxEntry | undefined>;
241
+ listScopes(): Promise<ReducerOutboxScopeSummary[]>;
172
242
  count(scope: string): Promise<number>;
173
243
  clear(scope: string): Promise<void>;
244
+ purgeScope(scope: string): Promise<number>;
174
245
  subscribe(listener: () => void): () => void;
175
246
  recoverInflight(scope: string): Promise<void>;
176
247
  private refreshShared;
248
+ /**
249
+ * Apply one atomic read-modify-write. `change` returns the replacement,
250
+ * `null` to delete the entry, or `undefined` to leave it untouched.
251
+ * Shared stores run the decision inside their own transaction so another
252
+ * tab's delivery cannot interleave; a deletion first fences the entry as
253
+ * `rejected` (never sendable), then removes it.
254
+ */
255
+ private transition;
177
256
  private updateShared;
178
257
  private hydrate;
179
258
  private persistPut;
@@ -183,3 +262,6 @@ export declare class StoreReducerOutbox implements ReducerOutbox {
183
262
  private degradeToMemory;
184
263
  }
185
264
  export declare function createReducerOutbox(options?: ReducerOutboxOptions): ReducerOutbox;
265
+ export declare const DEFAULT_OUTBOX_MAX_BACKOFF_MS = 30000;
266
+ /** Exponential backoff after `attempts` counted failures. */
267
+ export declare function outboxBackoffMs(attempts: number, maxBackoffMs?: number): number;
package/dist/outbox.js CHANGED
@@ -1,3 +1,11 @@
1
+ /** Entries whose optimistic prediction is still part of the local overlay. */
2
+ export function outboxEntryIsLive(entry) {
3
+ return entry.state !== "rejected";
4
+ }
5
+ /** Entries that still wait for delivery without user action. */
6
+ export function outboxEntryIsQueued(entry) {
7
+ return entry.state === "pending" || entry.state === "inflight" || entry.state === "committed";
8
+ }
1
9
  /**
2
10
  * A durable, totally ordered reducer queue.
3
11
  *
@@ -127,29 +135,8 @@ export class DexieReducerOutbox {
127
135
  }
128
136
  }
129
137
  async markInflight(id) {
130
- if (this.memoryOnly) {
131
- this.markInflightInMemory(id);
132
- return;
133
- }
134
- try {
135
- const database = await this.open();
136
- let updated;
137
- await database.transaction("rw", database.entries, async () => {
138
- const entry = await database.entries.get(id);
139
- if (!entry || entry.state === "inflight")
140
- return;
141
- updated = { ...entry, state: "inflight" };
142
- await database.entries.put(updated);
143
- });
144
- if (!updated)
145
- return;
146
- this.remember(updated);
147
- this.notify();
148
- }
149
- catch {
150
- this.degradeToMemory();
151
- this.markInflightInMemory(id);
152
- }
138
+ const { before, after } = await this.transition(id, markInflightChange);
139
+ return (after ?? before)?.state === "inflight";
153
140
  }
154
141
  async markPending(id) {
155
142
  if (this.memoryOnly) {
@@ -161,7 +148,7 @@ export class DexieReducerOutbox {
161
148
  let updated;
162
149
  await database.transaction("rw", database.entries, async () => {
163
150
  const entry = await database.entries.get(id);
164
- if (!entry || entry.state === "pending")
151
+ if (!entry || entry.state === "pending" || isParked(entry))
165
152
  return;
166
153
  updated = {
167
154
  ...entry,
@@ -225,40 +212,45 @@ export class DexieReducerOutbox {
225
212
  this.ackInMemory(id);
226
213
  }
227
214
  }
228
- async fail(id, error) {
229
- if (this.memoryOnly) {
230
- this.failInMemory(id, error);
231
- return;
232
- }
215
+ async fail(id, error, options = {}) {
216
+ const now = Date.now();
217
+ const { after } = await this.transition(id, (entry) => failChange(entry, error, now, options));
218
+ return after ?? undefined;
219
+ }
220
+ async reject(id, error) {
221
+ const now = Date.now();
222
+ const { after } = await this.transition(id, (entry) => rejectChange(entry, error, now));
223
+ return after ?? undefined;
224
+ }
225
+ async retry(id) {
226
+ const now = Date.now();
227
+ const { after } = await this.transition(id, (entry) => retryChange(entry, now));
228
+ return after ?? undefined;
229
+ }
230
+ async discard(id, states) {
231
+ const { before, after } = await this.transition(id, (entry) => (states.includes(entry.state) ? null : undefined));
232
+ return after === null ? before : undefined;
233
+ }
234
+ async listScopes() {
235
+ if (this.memoryOnly)
236
+ return summarizeScopes(this.sortedMemoryEntries());
233
237
  try {
234
- const database = await this.open();
235
- let updated;
236
- await database.transaction("rw", database.entries, async () => {
237
- const entry = await database.entries.get(id);
238
- if (!entry)
239
- return;
240
- updated = failedEntry(entry, error, Date.now());
241
- await database.entries.put(updated);
242
- });
243
- if (!updated)
244
- return;
245
- this.remember(updated);
246
- this.notify();
238
+ return summarizeScopes(await (await this.open()).entries.toArray());
247
239
  }
248
240
  catch {
249
241
  this.degradeToMemory();
250
- this.failInMemory(id, error);
242
+ return summarizeScopes(this.sortedMemoryEntries());
251
243
  }
252
244
  }
253
245
  async count(scope) {
254
246
  if (this.memoryOnly)
255
- return this.sortedMemoryEntries(scope).length;
247
+ return this.sortedMemoryEntries(scope).filter(outboxEntryIsQueued).length;
256
248
  try {
257
- return await (await this.open()).entries.where("scope").equals(scope).count();
249
+ return await (await this.open()).entries.where("scope").equals(scope).filter(outboxEntryIsQueued).count();
258
250
  }
259
251
  catch {
260
252
  this.degradeToMemory();
261
- return this.sortedMemoryEntries(scope).length;
253
+ return this.sortedMemoryEntries(scope).filter(outboxEntryIsQueued).length;
262
254
  }
263
255
  }
264
256
  async clear(scope) {
@@ -279,6 +271,27 @@ export class DexieReducerOutbox {
279
271
  }
280
272
  this.notify();
281
273
  }
274
+ async purgeScope(scope) {
275
+ let removed = 0;
276
+ for (const [id, entry] of this.memoryEntries) {
277
+ if (entry.scope !== scope)
278
+ continue;
279
+ this.memoryEntries.delete(id);
280
+ removed += 1;
281
+ }
282
+ if (!this.memoryOnly) {
283
+ try {
284
+ // Foreign scopes are never hydrated into memory, so delete by index.
285
+ removed = Math.max(removed, await (await this.open()).entries.where("scope").equals(scope).delete());
286
+ }
287
+ catch {
288
+ this.degradeToMemory();
289
+ }
290
+ }
291
+ if (removed > 0)
292
+ this.notify();
293
+ return removed;
294
+ }
282
295
  subscribe(listener) {
283
296
  this.listeners.add(listener);
284
297
  return () => this.listeners.delete(listener);
@@ -303,16 +316,9 @@ export class DexieReducerOutbox {
303
316
  this.notify();
304
317
  return this.sortedMemoryEntries(scope).map(cloneEntry);
305
318
  }
306
- markInflightInMemory(id) {
307
- const entry = this.memoryEntries.get(id);
308
- if (!entry || entry.state === "inflight")
309
- return;
310
- this.memoryEntries.set(id, { ...entry, state: "inflight" });
311
- this.notify();
312
- }
313
319
  markPendingInMemory(id) {
314
320
  const entry = this.memoryEntries.get(id);
315
- if (!entry || entry.state === "pending")
321
+ if (!entry || entry.state === "pending" || isParked(entry))
316
322
  return;
317
323
  this.memoryEntries.set(id, {
318
324
  ...entry,
@@ -334,12 +340,53 @@ export class DexieReducerOutbox {
334
340
  return;
335
341
  this.notify();
336
342
  }
337
- failInMemory(id, error) {
343
+ /**
344
+ * Apply one atomic read-modify-write. `change` returns the replacement,
345
+ * `null` to delete the entry, or `undefined` to leave it untouched.
346
+ */
347
+ async transition(id, change) {
348
+ if (this.memoryOnly)
349
+ return this.transitionInMemory(id, change);
350
+ try {
351
+ const database = await this.open();
352
+ let before;
353
+ let after;
354
+ await database.transaction("rw", database.entries, async () => {
355
+ const entry = await database.entries.get(id);
356
+ if (!entry)
357
+ return;
358
+ before = entry;
359
+ after = change(cloneEntry(entry));
360
+ if (after === null)
361
+ await database.entries.delete(id);
362
+ else if (after)
363
+ await database.entries.put(after);
364
+ });
365
+ if (after === null)
366
+ this.memoryEntries.delete(id);
367
+ else if (after)
368
+ this.remember(after);
369
+ if (after !== undefined)
370
+ this.notify();
371
+ return { before: before && cloneEntry(before), after: after ? cloneEntry(after) : after };
372
+ }
373
+ catch {
374
+ this.degradeToMemory();
375
+ return this.transitionInMemory(id, change);
376
+ }
377
+ }
378
+ transitionInMemory(id, change) {
338
379
  const entry = this.memoryEntries.get(id);
339
380
  if (!entry)
340
- return;
341
- this.memoryEntries.set(id, failedEntry(entry, error, Date.now()));
342
- this.notify();
381
+ return {};
382
+ const after = change(cloneEntry(entry));
383
+ if (after === null)
384
+ this.memoryEntries.delete(id);
385
+ else if (after)
386
+ this.memoryEntries.set(id, cloneEntry(after));
387
+ if (after !== undefined)
388
+ this.notify();
389
+ return { before: cloneEntry(entry), after: after ? cloneEntry(after) : after };
343
390
  }
344
391
  sortedMemoryEntries(scope) {
345
392
  return [...this.memoryEntries.values()]
@@ -512,28 +559,18 @@ export class StoreReducerOutbox {
512
559
  }
513
560
  async markInflight(id) {
514
561
  await this.ready;
515
- if (this.store.update) {
516
- await this.updateShared(id, entry => ({ ...entry, state: 'inflight' }));
517
- this.notify();
518
- return;
519
- }
520
- const entry = this.entries.get(id);
521
- if (!entry || entry.state === "inflight")
522
- return;
523
- const updated = { ...entry, state: "inflight" };
524
- this.entries.set(id, updated);
525
- await this.persistPut(updated);
526
- this.notify();
562
+ const { before, after } = await this.transition(id, markInflightChange);
563
+ return (after ?? before)?.state === "inflight";
527
564
  }
528
565
  async markPending(id) {
529
566
  await this.ready;
530
567
  if (this.store.update) {
531
- await this.updateShared(id, entry => ({ ...entry, state: 'pending', nextAttemptAt: Date.now(), lastError: undefined }));
568
+ await this.updateShared(id, entry => isParked(entry) ? entry : ({ ...entry, state: 'pending', nextAttemptAt: Date.now(), lastError: undefined }));
532
569
  this.notify();
533
570
  return;
534
571
  }
535
572
  const entry = this.entries.get(id);
536
- if (!entry || entry.state === "pending")
573
+ if (!entry || entry.state === "pending" || isParked(entry))
537
574
  return;
538
575
  const updated = {
539
576
  ...entry,
@@ -567,25 +604,39 @@ export class StoreReducerOutbox {
567
604
  await this.persistDelete(id);
568
605
  this.notify();
569
606
  }
570
- async fail(id, error) {
607
+ async fail(id, error, options = {}) {
571
608
  await this.ready;
572
- if (this.store.update) {
573
- await this.updateShared(id, entry => failedEntry(entry, error, Date.now()));
574
- this.notify();
575
- return;
576
- }
577
- const entry = this.entries.get(id);
578
- if (!entry)
579
- return;
580
- const updated = failedEntry(entry, error, Date.now());
581
- this.entries.set(id, updated);
582
- await this.persistPut(updated);
583
- this.notify();
609
+ const now = Date.now();
610
+ const { after } = await this.transition(id, (entry) => failChange(entry, error, now, options));
611
+ return after ?? undefined;
612
+ }
613
+ async reject(id, error) {
614
+ await this.ready;
615
+ const now = Date.now();
616
+ const { after } = await this.transition(id, (entry) => rejectChange(entry, error, now));
617
+ return after ?? undefined;
618
+ }
619
+ async retry(id) {
620
+ await this.ready;
621
+ const now = Date.now();
622
+ const { after } = await this.transition(id, (entry) => retryChange(entry, now));
623
+ return after ?? undefined;
624
+ }
625
+ async discard(id, states) {
626
+ await this.ready;
627
+ const { before, after } = await this.transition(id, (entry) => (states.includes(entry.state) ? null : undefined));
628
+ return after === null ? before : undefined;
629
+ }
630
+ async listScopes() {
631
+ await this.ready;
632
+ if (this.store.shared && !this.memoryOnly)
633
+ return summarizeScopes(await this.store.load());
634
+ return summarizeScopes([...this.entries.values()]);
584
635
  }
585
636
  async count(scope) {
586
637
  await this.ready;
587
638
  await this.refreshShared(scope);
588
- return this.sortedEntries(scope).length;
639
+ return this.sortedEntries(scope).filter(outboxEntryIsQueued).length;
589
640
  }
590
641
  async clear(scope) {
591
642
  await this.ready;
@@ -602,6 +653,30 @@ export class StoreReducerOutbox {
602
653
  await Promise.all(ids.map((id) => this.persistDelete(id)));
603
654
  this.notify();
604
655
  }
656
+ async purgeScope(scope) {
657
+ await this.ready;
658
+ const persisted = this.store.shared && !this.memoryOnly ? await this.store.load(scope) : [];
659
+ const ids = new Set(persisted.filter((entry) => entry.scope === scope).map((entry) => entry.id));
660
+ for (const [id, entry] of this.entries) {
661
+ if (entry.scope !== scope)
662
+ continue;
663
+ this.entries.delete(id);
664
+ ids.add(id);
665
+ }
666
+ if (!this.memoryOnly) {
667
+ try {
668
+ await this.store.clear(scope);
669
+ }
670
+ catch (error) {
671
+ if (this.store.strictPersistence)
672
+ throw error;
673
+ this.degradeToMemory();
674
+ }
675
+ }
676
+ if (ids.size > 0)
677
+ this.notify();
678
+ return ids.size;
679
+ }
605
680
  subscribe(listener) {
606
681
  this.listeners.add(listener);
607
682
  return () => this.listeners.delete(listener);
@@ -621,6 +696,54 @@ export class StoreReducerOutbox {
621
696
  for (const entry of entries)
622
697
  this.entries.set(entry.id, cloneEntry(entry));
623
698
  }
699
+ /**
700
+ * Apply one atomic read-modify-write. `change` returns the replacement,
701
+ * `null` to delete the entry, or `undefined` to leave it untouched.
702
+ * Shared stores run the decision inside their own transaction so another
703
+ * tab's delivery cannot interleave; a deletion first fences the entry as
704
+ * `rejected` (never sendable), then removes it.
705
+ */
706
+ async transition(id, change) {
707
+ if (this.store.update && !this.memoryOnly) {
708
+ let before;
709
+ let decided;
710
+ const stored = await this.store.update(id, (entry) => {
711
+ before = cloneEntry(entry);
712
+ decided = change(cloneEntry(entry));
713
+ if (decided === undefined)
714
+ return entry;
715
+ if (decided === null)
716
+ return { ...entry, state: "rejected", lastError: entry.lastError ?? "Discarded" };
717
+ return decided;
718
+ });
719
+ if (decided === null) {
720
+ this.entries.delete(id);
721
+ await this.persistDelete(id);
722
+ }
723
+ else if (stored)
724
+ this.entries.set(id, cloneEntry(stored));
725
+ else
726
+ this.entries.delete(id);
727
+ if (decided !== undefined)
728
+ this.notify();
729
+ return { before, after: decided === null ? null : decided === undefined ? undefined : stored && cloneEntry(stored) };
730
+ }
731
+ const entry = this.entries.get(id);
732
+ if (!entry)
733
+ return {};
734
+ const after = change(cloneEntry(entry));
735
+ if (after === null) {
736
+ this.entries.delete(id);
737
+ await this.persistDelete(id);
738
+ }
739
+ else if (after) {
740
+ this.entries.set(id, after);
741
+ await this.persistPut(after);
742
+ }
743
+ if (after !== undefined)
744
+ this.notify();
745
+ return { before: cloneEntry(entry), after: after ? cloneEntry(after) : after };
746
+ }
624
747
  async updateShared(id, change) {
625
748
  const entry = await this.store.update(id, change);
626
749
  if (entry)
@@ -703,6 +826,11 @@ function firstReady(entries, now) {
703
826
  // a newer pending write to the same entity.
704
827
  if (entry.state === "committed")
705
828
  continue;
829
+ // Parked (failed) and rejected records wait for the app, not the server.
830
+ // They must not hold every later intent hostage. Later intents that truly
831
+ // depended on them are validated by the server and rejected on their own.
832
+ if (entry.state === "failed" || entry.state === "rejected")
833
+ continue;
706
834
  if (entry.state === "pending"
707
835
  && entry.nextAttemptAt <= now
708
836
  && !entry.entityKeys.some((key) => blockedEntityKeys.has(key))) {
@@ -713,16 +841,76 @@ function firstReady(entries, now) {
713
841
  }
714
842
  return undefined;
715
843
  }
716
- function failedEntry(entry, error, now) {
717
- const attempts = entry.attempts + 1;
844
+ export const DEFAULT_OUTBOX_MAX_BACKOFF_MS = 30_000;
845
+ /** Exponential backoff after `attempts` counted failures. */
846
+ export function outboxBackoffMs(attempts, maxBackoffMs = DEFAULT_OUTBOX_MAX_BACKOFF_MS) {
847
+ return Math.min(maxBackoffMs, 1_000 * (2 ** Math.min(attempts, 30)));
848
+ }
849
+ function failedEntry(entry, error, now, options = {}) {
850
+ const counted = options.countAttempt !== false;
851
+ const attempts = counted ? entry.attempts + 1 : entry.attempts;
852
+ const park = counted
853
+ && options.maxAttempts !== undefined
854
+ && attempts >= options.maxAttempts;
718
855
  return {
719
856
  ...entry,
720
857
  attempts,
721
- state: "pending",
722
- nextAttemptAt: now + Math.min(30_000, 1_000 * (2 ** attempts)),
858
+ state: park ? "failed" : "pending",
859
+ nextAttemptAt: now + (options.delayMs ?? outboxBackoffMs(attempts, options.maxBackoffMs)),
860
+ lastError: error,
861
+ ...(options.errorClass ? { lastErrorClass: options.errorClass } : {}),
862
+ ...(park ? { settledAt: now } : {}),
863
+ };
864
+ }
865
+ /** Failed and rejected records leave their state only through retry(). */
866
+ function isParked(entry) {
867
+ return entry.state === "failed" || entry.state === "rejected";
868
+ }
869
+ function markInflightChange(entry) {
870
+ // A parked or rejected record only leaves its state through retry().
871
+ if (entry.state === "inflight" || entry.state === "failed" || entry.state === "rejected")
872
+ return undefined;
873
+ return { ...entry, state: "inflight" };
874
+ }
875
+ function failChange(entry, error, now, options) {
876
+ // A concurrent discard/reject/commit already decided this entry's fate.
877
+ if (isParked(entry) || entry.state === "committed")
878
+ return undefined;
879
+ return failedEntry(entry, error, now, options);
880
+ }
881
+ function rejectChange(entry, error, now) {
882
+ return {
883
+ ...entry,
884
+ state: "rejected",
723
885
  lastError: error,
886
+ lastErrorClass: "rejected",
887
+ nextAttemptAt: now,
888
+ settledAt: now,
724
889
  };
725
890
  }
891
+ function retryChange(entry, now) {
892
+ if (entry.state !== "failed" && entry.state !== "rejected")
893
+ return undefined;
894
+ const { lastError: _error, lastErrorClass: _class, settledAt: _settled, ...rest } = entry;
895
+ // Same id, same idempotency key: the server replays instead of re-applying
896
+ // if an earlier attempt did commit before its response was lost.
897
+ return { ...rest, state: "pending", attempts: 0, nextAttemptAt: now };
898
+ }
899
+ function summarizeScopes(entries) {
900
+ const scopes = new Map();
901
+ for (const entry of entries) {
902
+ if (typeof entry.scope !== "string")
903
+ continue;
904
+ const summary = scopes.get(entry.scope);
905
+ if (summary) {
906
+ summary.count += 1;
907
+ summary.oldestCreatedAt = Math.min(summary.oldestCreatedAt, entry.createdAt);
908
+ }
909
+ else
910
+ scopes.set(entry.scope, { scope: entry.scope, count: 1, oldestCreatedAt: entry.createdAt });
911
+ }
912
+ return [...scopes.values()].sort((left, right) => left.oldestCreatedAt - right.oldestCreatedAt);
913
+ }
726
914
  function createIdempotencyKey() {
727
915
  if (globalThis.crypto?.randomUUID)
728
916
  return globalThis.crypto.randomUUID();