@gonvex/client 0.5.2-staging.2 → 0.5.2-staging.21

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.
Files changed (38) hide show
  1. package/README.md +68 -2
  2. package/dist/browser-upgrades.d.ts +19 -0
  3. package/dist/browser-upgrades.js +157 -0
  4. package/dist/browser-upgrades.js.map +1 -0
  5. package/dist/client-upgrades.d.ts +26 -0
  6. package/dist/client-upgrades.js +41 -0
  7. package/dist/client-upgrades.js.map +1 -0
  8. package/dist/control.d.ts +2 -0
  9. package/dist/control.js +1 -0
  10. package/dist/control.js.map +1 -1
  11. package/dist/index.d.ts +348 -9
  12. package/dist/index.js +1576 -192
  13. package/dist/index.js.map +1 -1
  14. package/dist/indexeddb-read-view.d.ts +24 -0
  15. package/dist/indexeddb-read-view.js +164 -0
  16. package/dist/indexeddb-read-view.js.map +1 -0
  17. package/dist/indexeddb-replica.d.ts +41 -3
  18. package/dist/indexeddb-replica.js +405 -111
  19. package/dist/indexeddb-replica.js.map +1 -1
  20. package/dist/local-replica.d.ts +127 -4
  21. package/dist/local-replica.js +836 -85
  22. package/dist/local-replica.js.map +1 -1
  23. package/dist/outbox.d.ts +118 -10
  24. package/dist/outbox.js +391 -89
  25. package/dist/outbox.js.map +1 -1
  26. package/dist/paged-read-view.d.ts +13 -0
  27. package/dist/paged-read-view.js +60 -0
  28. package/dist/paged-read-view.js.map +1 -0
  29. package/dist/query-expression.d.ts +39 -2
  30. package/dist/query-expression.js +106 -5
  31. package/dist/query-expression.js.map +1 -1
  32. package/dist/replica-integrity.d.ts +2 -0
  33. package/dist/replica-integrity.js +10 -0
  34. package/dist/replica-integrity.js.map +1 -1
  35. package/dist/replica-record.d.ts +14 -0
  36. package/dist/replica-record.js +46 -0
  37. package/dist/replica-record.js.map +1 -0
  38. package/package.json +4 -2
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
  *
@@ -38,13 +46,17 @@ export class DexieReducerOutbox {
38
46
  idempotencyKey: reducer.idempotencyKey ?? createIdempotencyKey(),
39
47
  entityKeys: [...(reducer.entityKeys ?? [])],
40
48
  patches: reducer.patches?.map(clonePatch),
49
+ ...(reducer.localExecution ? { localExecution: cloneValue(reducer.localExecution) } : {}),
41
50
  createdAt,
42
51
  attempts: 0,
43
52
  nextAttemptAt: createdAt,
44
53
  state: reducer.state ?? "pending",
45
54
  };
46
- if (this.memoryOnly)
55
+ if (this.memoryOnly) {
56
+ if (reducer.localExecution)
57
+ throw new Error("Durable storage is required for local reducer execution");
47
58
  return this.enqueueInMemory(entry);
59
+ }
48
60
  try {
49
61
  const database = await this.open();
50
62
  const id = await database.entries.add(entry);
@@ -53,7 +65,9 @@ export class DexieReducerOutbox {
53
65
  this.notify();
54
66
  return cloneEntry(stored);
55
67
  }
56
- catch {
68
+ catch (error) {
69
+ if (reducer.localExecution)
70
+ throw new Error("Could not persist local reducer; no changes were staged", { cause: error });
57
71
  this.degradeToMemory();
58
72
  return this.enqueueInMemory(entry);
59
73
  }
@@ -86,6 +100,26 @@ export class DexieReducerOutbox {
86
100
  return this.loadAllFromMemory(scope);
87
101
  }
88
102
  }
103
+ async list(scope) {
104
+ if (this.memoryOnly)
105
+ return this.sortedMemoryEntries(scope).map(cloneEntry);
106
+ const entries = await (await this.open()).entries.where("scope").equals(scope).sortBy("id");
107
+ this.replaceMemoryEntriesForScope(scope, entries);
108
+ return entries.map(cloneEntry);
109
+ }
110
+ async updateLocal(id, patches, execution) {
111
+ if (this.memoryOnly)
112
+ throw new Error("Durable storage is required for local reducer replay");
113
+ const database = await this.open();
114
+ await database.transaction("rw", database.entries, async () => {
115
+ const entry = await database.entries.get(id);
116
+ if (!entry)
117
+ return;
118
+ const updated = { ...entry, patches: patches.map(clonePatch), localExecution: cloneValue(execution) };
119
+ await database.entries.put(updated);
120
+ this.remember(updated);
121
+ });
122
+ }
89
123
  async nextReady(scope, now) {
90
124
  if (this.memoryOnly)
91
125
  return cloneOptionalEntry(firstReady(this.sortedMemoryEntries(scope), now));
@@ -101,29 +135,8 @@ export class DexieReducerOutbox {
101
135
  }
102
136
  }
103
137
  async markInflight(id) {
104
- if (this.memoryOnly) {
105
- this.markInflightInMemory(id);
106
- return;
107
- }
108
- try {
109
- const database = await this.open();
110
- let updated;
111
- await database.transaction("rw", database.entries, async () => {
112
- const entry = await database.entries.get(id);
113
- if (!entry || entry.state === "inflight")
114
- return;
115
- updated = { ...entry, state: "inflight" };
116
- await database.entries.put(updated);
117
- });
118
- if (!updated)
119
- return;
120
- this.remember(updated);
121
- this.notify();
122
- }
123
- catch {
124
- this.degradeToMemory();
125
- this.markInflightInMemory(id);
126
- }
138
+ const { before, after } = await this.transition(id, markInflightChange);
139
+ return (after ?? before)?.state === "inflight";
127
140
  }
128
141
  async markPending(id) {
129
142
  if (this.memoryOnly) {
@@ -135,7 +148,7 @@ export class DexieReducerOutbox {
135
148
  let updated;
136
149
  await database.transaction("rw", database.entries, async () => {
137
150
  const entry = await database.entries.get(id);
138
- if (!entry || entry.state === "pending")
151
+ if (!entry || entry.state === "pending" || isParked(entry))
139
152
  return;
140
153
  updated = {
141
154
  ...entry,
@@ -199,40 +212,45 @@ export class DexieReducerOutbox {
199
212
  this.ackInMemory(id);
200
213
  }
201
214
  }
202
- async fail(id, error) {
203
- if (this.memoryOnly) {
204
- this.failInMemory(id, error);
205
- return;
206
- }
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());
207
237
  try {
208
- const database = await this.open();
209
- let updated;
210
- await database.transaction("rw", database.entries, async () => {
211
- const entry = await database.entries.get(id);
212
- if (!entry)
213
- return;
214
- updated = failedEntry(entry, error, Date.now());
215
- await database.entries.put(updated);
216
- });
217
- if (!updated)
218
- return;
219
- this.remember(updated);
220
- this.notify();
238
+ return summarizeScopes(await (await this.open()).entries.toArray());
221
239
  }
222
240
  catch {
223
241
  this.degradeToMemory();
224
- this.failInMemory(id, error);
242
+ return summarizeScopes(this.sortedMemoryEntries());
225
243
  }
226
244
  }
227
245
  async count(scope) {
228
246
  if (this.memoryOnly)
229
- return this.sortedMemoryEntries(scope).length;
247
+ return this.sortedMemoryEntries(scope).filter(outboxEntryIsQueued).length;
230
248
  try {
231
- 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();
232
250
  }
233
251
  catch {
234
252
  this.degradeToMemory();
235
- return this.sortedMemoryEntries(scope).length;
253
+ return this.sortedMemoryEntries(scope).filter(outboxEntryIsQueued).length;
236
254
  }
237
255
  }
238
256
  async clear(scope) {
@@ -253,6 +271,27 @@ export class DexieReducerOutbox {
253
271
  }
254
272
  this.notify();
255
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
+ }
256
295
  subscribe(listener) {
257
296
  this.listeners.add(listener);
258
297
  return () => this.listeners.delete(listener);
@@ -277,16 +316,9 @@ export class DexieReducerOutbox {
277
316
  this.notify();
278
317
  return this.sortedMemoryEntries(scope).map(cloneEntry);
279
318
  }
280
- markInflightInMemory(id) {
281
- const entry = this.memoryEntries.get(id);
282
- if (!entry || entry.state === "inflight")
283
- return;
284
- this.memoryEntries.set(id, { ...entry, state: "inflight" });
285
- this.notify();
286
- }
287
319
  markPendingInMemory(id) {
288
320
  const entry = this.memoryEntries.get(id);
289
- if (!entry || entry.state === "pending")
321
+ if (!entry || entry.state === "pending" || isParked(entry))
290
322
  return;
291
323
  this.memoryEntries.set(id, {
292
324
  ...entry,
@@ -308,12 +340,53 @@ export class DexieReducerOutbox {
308
340
  return;
309
341
  this.notify();
310
342
  }
311
- 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) {
312
379
  const entry = this.memoryEntries.get(id);
313
380
  if (!entry)
314
- return;
315
- this.memoryEntries.set(id, failedEntry(entry, error, Date.now()));
316
- 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 };
317
390
  }
318
391
  sortedMemoryEntries(scope) {
319
392
  return [...this.memoryEntries.values()]
@@ -405,31 +478,47 @@ export class StoreReducerOutbox {
405
478
  constructor(store, options = {}) {
406
479
  this.store = store;
407
480
  this.memoryOnly = options.enabled === false;
408
- this.ready = this.memoryOnly ? Promise.resolve() : this.hydrate();
481
+ this.ready = this.memoryOnly || store.shared ? Promise.resolve() : this.hydrate();
409
482
  }
410
483
  async enqueue(reducer) {
411
484
  await this.ready;
412
485
  const createdAt = Date.now();
413
- const entry = {
414
- id: this.nextId++,
486
+ const draft = {
415
487
  scope: reducer.scope,
416
488
  path: reducer.path,
417
489
  args: cloneValue(reducer.args),
418
490
  idempotencyKey: reducer.idempotencyKey ?? createIdempotencyKey(),
419
491
  entityKeys: [...(reducer.entityKeys ?? [])],
420
492
  patches: reducer.patches?.map(clonePatch),
493
+ ...(reducer.localExecution ? { localExecution: cloneValue(reducer.localExecution) } : {}),
421
494
  createdAt,
422
495
  attempts: 0,
423
496
  nextAttemptAt: createdAt,
424
497
  state: reducer.state ?? "pending",
425
498
  };
499
+ let entry;
500
+ if (reducer.localExecution && this.memoryOnly)
501
+ throw new Error("Durable storage is required for local reducer execution");
502
+ if (reducer.localExecution && this.store.append) {
503
+ entry = await this.store.append(draft);
504
+ }
505
+ else {
506
+ entry = { ...draft, id: this.store.allocateId ? await this.store.allocateId() : this.nextId++ };
507
+ if (reducer.localExecution)
508
+ await this.store.put(cloneEntry(entry));
509
+ else
510
+ await this.persistPut(entry);
511
+ }
426
512
  this.entries.set(entry.id, entry);
427
- await this.persistPut(entry);
428
513
  this.notify();
429
514
  return cloneEntry(entry);
430
515
  }
431
516
  async loadAll(scope) {
432
517
  await this.ready;
518
+ await this.refreshShared(scope);
519
+ // Only the holder of the cross-tab delivery lock may recover inflight work.
520
+ if (this.store.shared)
521
+ return this.sortedEntries(scope).map(cloneEntry);
433
522
  const recovered = [];
434
523
  for (const [id, entry] of this.entries) {
435
524
  if (entry.scope !== scope || entry.state !== "inflight")
@@ -443,24 +532,45 @@ export class StoreReducerOutbox {
443
532
  this.notify();
444
533
  return this.sortedEntries(scope).map(cloneEntry);
445
534
  }
446
- async nextReady(scope, now) {
535
+ async list(scope) {
447
536
  await this.ready;
448
- return cloneOptionalEntry(firstReady(this.sortedEntries(scope), now));
537
+ await this.refreshShared(scope);
538
+ return this.sortedEntries(scope).map(cloneEntry);
449
539
  }
450
- async markInflight(id) {
540
+ async updateLocal(id, patches, execution) {
451
541
  await this.ready;
542
+ if (this.memoryOnly)
543
+ throw new Error("Durable storage is required for local reducer replay");
544
+ if (this.store.update) {
545
+ await this.updateShared(id, entry => ({ ...entry, patches: patches.map(clonePatch), localExecution: cloneValue(execution) }));
546
+ return;
547
+ }
452
548
  const entry = this.entries.get(id);
453
- if (!entry || entry.state === "inflight")
549
+ if (!entry)
454
550
  return;
455
- const updated = { ...entry, state: "inflight" };
551
+ const updated = { ...entry, patches: patches.map(clonePatch), localExecution: cloneValue(execution) };
552
+ await this.store.put(cloneEntry(updated));
456
553
  this.entries.set(id, updated);
457
- await this.persistPut(updated);
458
- this.notify();
554
+ }
555
+ async nextReady(scope, now) {
556
+ await this.ready;
557
+ await this.refreshShared(scope);
558
+ return cloneOptionalEntry(firstReady(this.sortedEntries(scope), now));
559
+ }
560
+ async markInflight(id) {
561
+ await this.ready;
562
+ const { before, after } = await this.transition(id, markInflightChange);
563
+ return (after ?? before)?.state === "inflight";
459
564
  }
460
565
  async markPending(id) {
461
566
  await this.ready;
567
+ if (this.store.update) {
568
+ await this.updateShared(id, entry => isParked(entry) ? entry : ({ ...entry, state: 'pending', nextAttemptAt: Date.now(), lastError: undefined }));
569
+ this.notify();
570
+ return;
571
+ }
462
572
  const entry = this.entries.get(id);
463
- if (!entry || entry.state === "pending")
573
+ if (!entry || entry.state === "pending" || isParked(entry))
464
574
  return;
465
575
  const updated = {
466
576
  ...entry,
@@ -474,6 +584,11 @@ export class StoreReducerOutbox {
474
584
  }
475
585
  async markCommitted(id) {
476
586
  await this.ready;
587
+ if (this.store.update) {
588
+ await this.updateShared(id, entry => ({ ...entry, state: 'committed' }));
589
+ this.notify();
590
+ return;
591
+ }
477
592
  const entry = this.entries.get(id);
478
593
  if (!entry || entry.state === "committed")
479
594
  return;
@@ -484,27 +599,48 @@ export class StoreReducerOutbox {
484
599
  }
485
600
  async ack(id) {
486
601
  await this.ready;
487
- if (!this.entries.delete(id))
602
+ if (!this.entries.delete(id) && !this.store.shared)
488
603
  return;
489
604
  await this.persistDelete(id);
490
605
  this.notify();
491
606
  }
492
- async fail(id, error) {
607
+ async fail(id, error, options = {}) {
493
608
  await this.ready;
494
- const entry = this.entries.get(id);
495
- if (!entry)
496
- return;
497
- const updated = failedEntry(entry, error, Date.now());
498
- this.entries.set(id, updated);
499
- await this.persistPut(updated);
500
- 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()]);
501
635
  }
502
636
  async count(scope) {
503
637
  await this.ready;
504
- return this.sortedEntries(scope).length;
638
+ await this.refreshShared(scope);
639
+ return this.sortedEntries(scope).filter(outboxEntryIsQueued).length;
505
640
  }
506
641
  async clear(scope) {
507
642
  await this.ready;
643
+ await this.refreshShared(scope);
508
644
  // Delete by snapshotted id, never scope-wide, so an enqueue racing this
509
645
  // clear is not swallowed by a later store delete.
510
646
  const ids = [];
@@ -517,10 +653,104 @@ export class StoreReducerOutbox {
517
653
  await Promise.all(ids.map((id) => this.persistDelete(id)));
518
654
  this.notify();
519
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
+ }
520
680
  subscribe(listener) {
521
681
  this.listeners.add(listener);
522
682
  return () => this.listeners.delete(listener);
523
683
  }
684
+ async recoverInflight(scope) {
685
+ await this.ready;
686
+ await this.refreshShared(scope);
687
+ for (const entry of this.sortedEntries(scope))
688
+ if (entry.state === 'inflight')
689
+ await this.markPending(entry.id);
690
+ }
691
+ async refreshShared(scope) {
692
+ if (!this.store.shared || this.memoryOnly)
693
+ return;
694
+ const entries = await this.store.load(scope);
695
+ this.entries.clear();
696
+ for (const entry of entries)
697
+ this.entries.set(entry.id, cloneEntry(entry));
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
+ }
747
+ async updateShared(id, change) {
748
+ const entry = await this.store.update(id, change);
749
+ if (entry)
750
+ this.entries.set(id, cloneEntry(entry));
751
+ else
752
+ this.entries.delete(id);
753
+ }
524
754
  async hydrate() {
525
755
  try {
526
756
  for (const entry of await this.store.load()) {
@@ -528,7 +758,9 @@ export class StoreReducerOutbox {
528
758
  this.nextId = Math.max(this.nextId, entry.id + 1);
529
759
  }
530
760
  }
531
- catch {
761
+ catch (error) {
762
+ if (this.store.strictPersistence)
763
+ throw error;
532
764
  this.degradeToMemory();
533
765
  }
534
766
  }
@@ -538,7 +770,9 @@ export class StoreReducerOutbox {
538
770
  try {
539
771
  await this.store.put(cloneEntry(entry));
540
772
  }
541
- catch {
773
+ catch (error) {
774
+ if (this.store.strictPersistence)
775
+ throw error;
542
776
  this.degradeToMemory();
543
777
  }
544
778
  }
@@ -548,7 +782,9 @@ export class StoreReducerOutbox {
548
782
  try {
549
783
  await this.store.delete(id);
550
784
  }
551
- catch {
785
+ catch (error) {
786
+ if (this.store.strictPersistence)
787
+ throw error;
552
788
  this.degradeToMemory();
553
789
  }
554
790
  }
@@ -590,6 +826,11 @@ function firstReady(entries, now) {
590
826
  // a newer pending write to the same entity.
591
827
  if (entry.state === "committed")
592
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;
593
834
  if (entry.state === "pending"
594
835
  && entry.nextAttemptAt <= now
595
836
  && !entry.entityKeys.some((key) => blockedEntityKeys.has(key))) {
@@ -600,16 +841,76 @@ function firstReady(entries, now) {
600
841
  }
601
842
  return undefined;
602
843
  }
603
- function failedEntry(entry, error, now) {
604
- 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;
605
855
  return {
606
856
  ...entry,
607
857
  attempts,
608
- state: "pending",
609
- 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",
610
885
  lastError: error,
886
+ lastErrorClass: "rejected",
887
+ nextAttemptAt: now,
888
+ settledAt: now,
611
889
  };
612
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
+ }
613
914
  function createIdempotencyKey() {
614
915
  if (globalThis.crypto?.randomUUID)
615
916
  return globalThis.crypto.randomUUID();
@@ -621,6 +922,7 @@ function cloneEntry(entry) {
621
922
  args: cloneValue(entry.args),
622
923
  entityKeys: [...entry.entityKeys],
623
924
  patches: entry.patches?.map(clonePatch),
925
+ ...(entry.localExecution ? { localExecution: cloneValue(entry.localExecution) } : {}),
624
926
  };
625
927
  }
626
928
  function cloneOptionalEntry(entry) {