@gonvex/client 0.5.2-staging.13 → 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.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()]
@@ -431,13 +478,12 @@ export class StoreReducerOutbox {
431
478
  constructor(store, options = {}) {
432
479
  this.store = store;
433
480
  this.memoryOnly = options.enabled === false;
434
- this.ready = this.memoryOnly ? Promise.resolve() : this.hydrate();
481
+ this.ready = this.memoryOnly || store.shared ? Promise.resolve() : this.hydrate();
435
482
  }
436
483
  async enqueue(reducer) {
437
484
  await this.ready;
438
485
  const createdAt = Date.now();
439
- const entry = {
440
- id: this.store.allocateId ? await this.store.allocateId() : this.nextId++,
486
+ const draft = {
441
487
  scope: reducer.scope,
442
488
  path: reducer.path,
443
489
  args: cloneValue(reducer.args),
@@ -450,19 +496,29 @@ export class StoreReducerOutbox {
450
496
  nextAttemptAt: createdAt,
451
497
  state: reducer.state ?? "pending",
452
498
  };
453
- if (reducer.localExecution) {
454
- if (this.memoryOnly)
455
- throw new Error("Durable storage is required for local reducer execution");
456
- await this.store.put(cloneEntry(entry));
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);
457
511
  }
458
- else
459
- await this.persistPut(entry);
460
512
  this.entries.set(entry.id, entry);
461
513
  this.notify();
462
514
  return cloneEntry(entry);
463
515
  }
464
516
  async loadAll(scope) {
465
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);
466
522
  const recovered = [];
467
523
  for (const [id, entry] of this.entries) {
468
524
  if (entry.scope !== scope || entry.state !== "inflight")
@@ -478,12 +534,17 @@ export class StoreReducerOutbox {
478
534
  }
479
535
  async list(scope) {
480
536
  await this.ready;
537
+ await this.refreshShared(scope);
481
538
  return this.sortedEntries(scope).map(cloneEntry);
482
539
  }
483
540
  async updateLocal(id, patches, execution) {
484
541
  await this.ready;
485
542
  if (this.memoryOnly)
486
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
+ }
487
548
  const entry = this.entries.get(id);
488
549
  if (!entry)
489
550
  return;
@@ -493,22 +554,23 @@ export class StoreReducerOutbox {
493
554
  }
494
555
  async nextReady(scope, now) {
495
556
  await this.ready;
557
+ await this.refreshShared(scope);
496
558
  return cloneOptionalEntry(firstReady(this.sortedEntries(scope), now));
497
559
  }
498
560
  async markInflight(id) {
499
561
  await this.ready;
500
- const entry = this.entries.get(id);
501
- if (!entry || entry.state === "inflight")
502
- return;
503
- const updated = { ...entry, state: "inflight" };
504
- this.entries.set(id, updated);
505
- await this.persistPut(updated);
506
- this.notify();
562
+ const { before, after } = await this.transition(id, markInflightChange);
563
+ return (after ?? before)?.state === "inflight";
507
564
  }
508
565
  async markPending(id) {
509
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
+ }
510
572
  const entry = this.entries.get(id);
511
- if (!entry || entry.state === "pending")
573
+ if (!entry || entry.state === "pending" || isParked(entry))
512
574
  return;
513
575
  const updated = {
514
576
  ...entry,
@@ -522,6 +584,11 @@ export class StoreReducerOutbox {
522
584
  }
523
585
  async markCommitted(id) {
524
586
  await this.ready;
587
+ if (this.store.update) {
588
+ await this.updateShared(id, entry => ({ ...entry, state: 'committed' }));
589
+ this.notify();
590
+ return;
591
+ }
525
592
  const entry = this.entries.get(id);
526
593
  if (!entry || entry.state === "committed")
527
594
  return;
@@ -532,27 +599,48 @@ export class StoreReducerOutbox {
532
599
  }
533
600
  async ack(id) {
534
601
  await this.ready;
535
- if (!this.entries.delete(id))
602
+ if (!this.entries.delete(id) && !this.store.shared)
536
603
  return;
537
604
  await this.persistDelete(id);
538
605
  this.notify();
539
606
  }
540
- async fail(id, error) {
607
+ async fail(id, error, options = {}) {
541
608
  await this.ready;
542
- const entry = this.entries.get(id);
543
- if (!entry)
544
- return;
545
- const updated = failedEntry(entry, error, Date.now());
546
- this.entries.set(id, updated);
547
- await this.persistPut(updated);
548
- 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()]);
549
635
  }
550
636
  async count(scope) {
551
637
  await this.ready;
552
- return this.sortedEntries(scope).length;
638
+ await this.refreshShared(scope);
639
+ return this.sortedEntries(scope).filter(outboxEntryIsQueued).length;
553
640
  }
554
641
  async clear(scope) {
555
642
  await this.ready;
643
+ await this.refreshShared(scope);
556
644
  // Delete by snapshotted id, never scope-wide, so an enqueue racing this
557
645
  // clear is not swallowed by a later store delete.
558
646
  const ids = [];
@@ -565,10 +653,104 @@ export class StoreReducerOutbox {
565
653
  await Promise.all(ids.map((id) => this.persistDelete(id)));
566
654
  this.notify();
567
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
+ }
568
680
  subscribe(listener) {
569
681
  this.listeners.add(listener);
570
682
  return () => this.listeners.delete(listener);
571
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
+ }
572
754
  async hydrate() {
573
755
  try {
574
756
  for (const entry of await this.store.load()) {
@@ -644,6 +826,11 @@ function firstReady(entries, now) {
644
826
  // a newer pending write to the same entity.
645
827
  if (entry.state === "committed")
646
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;
647
834
  if (entry.state === "pending"
648
835
  && entry.nextAttemptAt <= now
649
836
  && !entry.entityKeys.some((key) => blockedEntityKeys.has(key))) {
@@ -654,16 +841,76 @@ function firstReady(entries, now) {
654
841
  }
655
842
  return undefined;
656
843
  }
657
- function failedEntry(entry, error, now) {
658
- 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;
659
855
  return {
660
856
  ...entry,
661
857
  attempts,
662
- state: "pending",
663
- nextAttemptAt: now + Math.min(30_000, 1_000 * (2 ** attempts)),
858
+ state: park ? "failed" : "pending",
859
+ nextAttemptAt: now + (options.delayMs ?? outboxBackoffMs(attempts, options.maxBackoffMs)),
664
860
  lastError: error,
861
+ ...(options.errorClass ? { lastErrorClass: options.errorClass } : {}),
862
+ ...(park ? { settledAt: now } : {}),
665
863
  };
666
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",
885
+ lastError: error,
886
+ lastErrorClass: "rejected",
887
+ nextAttemptAt: now,
888
+ settledAt: now,
889
+ };
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
+ }
667
914
  function createIdempotencyKey() {
668
915
  if (globalThis.crypto?.randomUUID)
669
916
  return globalThis.crypto.randomUUID();