@mmstack/resource 20.5.1 → 20.5.2

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.
@@ -193,6 +193,12 @@ class Cache {
193
193
  const value = syncTabs.deserialize(msg.entry.value);
194
194
  if (value === null)
195
195
  return;
196
+ // Last-write-wins by `updated` timestamp. If our local entry was
197
+ // written more recently than the broadcast we just received, the
198
+ // broadcast is stale (in-flight when we wrote locally) — drop it.
199
+ const existing = untracked(this.internal).get(msg.entry.key);
200
+ if (existing && existing.updated >= msg.entry.updated)
201
+ return;
196
202
  this.storeInternal(msg.entry.key, value, msg.entry.stale - msg.entry.updated, msg.entry.expiresAt - msg.entry.updated, true, false);
197
203
  }
198
204
  else if (msg.action === 'invalidate') {
@@ -340,6 +346,31 @@ class Cache {
340
346
  invalidate(key) {
341
347
  this.invalidateInternal(key);
342
348
  }
349
+ /**
350
+ * Invalidates every cache entry whose key starts with `prefix`. Common after a
351
+ * list-mutating operation (e.g. invalidate every paginated `GET /api/posts*`
352
+ * after a POST). Returns the number of entries removed.
353
+ *
354
+ * @example
355
+ * cache.invalidatePrefix('GET https://api.example.com/posts');
356
+ */
357
+ invalidatePrefix(prefix) {
358
+ return this.invalidateWhere((key) => key.startsWith(prefix));
359
+ }
360
+ /**
361
+ * Invalidates every cache entry whose key matches the predicate. Use for
362
+ * arbitrary bulk invalidation that doesn't fit prefix matching (e.g.
363
+ * "everything containing `userId=42`"). Returns the number of entries removed.
364
+ *
365
+ * @example
366
+ * cache.invalidateWhere((key) => key.includes('/me/'));
367
+ */
368
+ invalidateWhere(predicate) {
369
+ const keys = Array.from(untracked(this.internal).keys()).filter(predicate);
370
+ for (const key of keys)
371
+ this.invalidateInternal(key);
372
+ return keys.length;
373
+ }
343
374
  invalidateInternal(key, fromSync = false) {
344
375
  const entry = this.getUntracked(key);
345
376
  if (!entry)
@@ -682,7 +713,9 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
682
713
  });
683
714
  }
684
715
  return next(req).pipe(tap((event) => {
685
- if (event instanceof HttpResponse && event.ok) {
716
+ if (!(event instanceof HttpResponse))
717
+ return;
718
+ if (event.ok) {
686
719
  const cacheControl = parseCacheControlHeader(event);
687
720
  if (cacheControl.noStore && !opt.ignoreCacheControl)
688
721
  return;
@@ -701,6 +734,17 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
701
734
  })
702
735
  : event;
703
736
  cache.store(key, parsedResponse, staleTime, ttl, opt.persist);
737
+ return;
738
+ }
739
+ // 304 → server confirmed our cached entry is still valid. Re-stamp the
740
+ // existing entry so subsequent reads within the new freshness window
741
+ // don't trigger another revalidation round-trip.
742
+ if (event.status === 304 && entry) {
743
+ const cacheControl = parseCacheControlHeader(event);
744
+ const { staleTime, ttl } = opt.ignoreCacheControl
745
+ ? opt
746
+ : resolveTimings(cacheControl, opt.staleTime, opt.ttl);
747
+ cache.store(key, entry.value, staleTime, ttl, opt.persist);
704
748
  }
705
749
  }), map((event) => {
706
750
  // handle 304 responses due to eTag/last-modified
@@ -728,17 +772,18 @@ function catchValueError(resource, fallback) {
728
772
 
729
773
  /** @internal */
730
774
  const DEFAULT_OPTIONS = {
731
- treshold: 5,
775
+ threshold: 5,
732
776
  timeout: 30000,
733
777
  shouldFail: () => true,
734
778
  shouldFailForever: () => false,
735
779
  };
736
780
  /** @internal */
737
- function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldFail = () => true, shouldFailForever = () => false) {
781
+ function internalCeateCircuitBreaker(threshold = 5, resetTimeout = 30000, shouldFail = () => true, shouldFailForever = () => false) {
738
782
  const halfOpen = signal(false, ...(ngDevMode ? [{ debugName: "halfOpen" }] : []));
739
783
  const failureCount = signal(0, ...(ngDevMode ? [{ debugName: "failureCount" }] : []));
784
+ const failedForever = signal(false, ...(ngDevMode ? [{ debugName: "failedForever" }] : []));
740
785
  const status = computed(() => {
741
- if (failureCount() >= treshold)
786
+ if (failedForever() || failureCount() >= threshold)
742
787
  return 'OPEN';
743
788
  return halfOpen() ? 'HALF_OPEN' : 'CLOSED';
744
789
  }, ...(ngDevMode ? [{ debugName: "status" }] : []));
@@ -752,17 +797,17 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldF
752
797
  if (!untracked(isOpen))
753
798
  return;
754
799
  halfOpen.set(true);
755
- failureCount.set(treshold - 1);
800
+ failureCount.set(threshold - 1);
756
801
  };
757
- let failForeverResetId = null;
802
+ // Auto-probe effect: schedules a half-open retry after `resetTimeout` whenever
803
+ // the breaker is open, *unless* we've been failed forever (in which case only
804
+ // hardReset() can recover).
758
805
  const effectRef = effect((cleanup) => {
759
- if (!isOpen())
806
+ if (!isOpen() || failedForever())
760
807
  return;
761
808
  const timeout = setTimeout(tryOnce, resetTimeout);
762
- failForeverResetId = timeout;
763
809
  return cleanup(() => {
764
810
  clearTimeout(timeout);
765
- failForeverResetId = null;
766
811
  });
767
812
  }, ...(ngDevMode ? [{ debugName: "effectRef" }] : []));
768
813
  const failInternal = () => {
@@ -770,12 +815,8 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldF
770
815
  halfOpen.set(false);
771
816
  };
772
817
  const failForever = () => {
773
- if (failForeverResetId)
774
- clearTimeout(failForeverResetId);
775
- effectRef.destroy();
776
- failureCount.set(Infinity);
818
+ failedForever.set(true);
777
819
  halfOpen.set(false);
778
- return;
779
820
  };
780
821
  const fail = (err) => {
781
822
  if (shouldFailForever(err))
@@ -784,6 +825,11 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldF
784
825
  return failInternal();
785
826
  // If the error does not trigger a failure, we do nothing.
786
827
  };
828
+ const hardReset = () => {
829
+ failedForever.set(false);
830
+ failureCount.set(0);
831
+ halfOpen.set(false);
832
+ };
787
833
  return {
788
834
  status,
789
835
  isClosed,
@@ -791,6 +837,7 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldF
791
837
  fail,
792
838
  success,
793
839
  halfOpen: tryOnce,
840
+ hardReset,
794
841
  destroy: () => effectRef.destroy(),
795
842
  };
796
843
  }
@@ -809,18 +856,43 @@ function createNeverBrokenCircuitBreaker() {
809
856
  halfOpen: () => {
810
857
  // noop
811
858
  },
859
+ hardReset: () => {
860
+ // noop
861
+ },
812
862
  destroy: () => {
813
863
  // noop
814
864
  },
815
865
  };
816
866
  }
817
867
  const CB_DEFAULT_OPTIONS = new InjectionToken('MMSTACK_CIRCUIT_BREAKER_DEFAULT_OPTIONS');
868
+ /**
869
+ * Provides application-wide default options for {@link createCircuitBreaker}.
870
+ * Any `createCircuitBreaker()` call without explicit options (or with only
871
+ * partial options) merges these defaults in, so you can centralize threshold /
872
+ * timeout / failure-classifier behavior in one place.
873
+ *
874
+ * Per-call options always win over the provided defaults.
875
+ *
876
+ * @example
877
+ * ```ts
878
+ * bootstrapApplication(AppComponent, {
879
+ * providers: [
880
+ * provideCircuitBreakerDefaultOptions({
881
+ * threshold: 10,
882
+ * timeout: 60_000,
883
+ * shouldFailForever: (err) =>
884
+ * err instanceof HttpErrorResponse && [401, 403].includes(err.status),
885
+ * }),
886
+ * ],
887
+ * });
888
+ * ```
889
+ */
818
890
  function provideCircuitBreakerDefaultOptions(options) {
819
891
  return {
820
892
  provide: CB_DEFAULT_OPTIONS,
821
893
  useValue: {
822
894
  ...DEFAULT_OPTIONS,
823
- ...options,
895
+ ...normalizeThreshold(options),
824
896
  },
825
897
  };
826
898
  }
@@ -829,12 +901,23 @@ function injectCircuitBreakerOptions(injector = inject(Injector)) {
829
901
  optional: true,
830
902
  });
831
903
  }
904
+ /** @internal — strips the deprecated `treshold` field and folds it into `threshold` */
905
+ function normalizeThreshold(opt) {
906
+ if (!opt || typeof opt !== 'object' || 'isClosed' in opt)
907
+ return {};
908
+ const { treshold, threshold, ...rest } = opt;
909
+ return {
910
+ ...rest,
911
+ threshold: threshold ?? treshold,
912
+ };
913
+ }
832
914
  /**
833
915
  * Creates a circuit breaker instance.
834
916
  *
835
917
  * @param options - Configuration options for the circuit breaker. Can be:
836
- * - `undefined`: Creates a "no-op" circuit breaker that is always open (never trips).
837
- * - `true`: Creates a circuit breaker with default settings (threshold: 5, timeout: 30000ms).
918
+ * - `undefined`: Uses defaults (threshold: 5, timeout: 30000ms) or provided defaults via {@link provideCircuitBreakerDefaultOptions}.
919
+ * - `false`: Creates a "no-op" circuit breaker that is always closed (never trips).
920
+ * - `true`: Creates a circuit breaker with default settings.
838
921
  * - `CircuitBreaker`: Reuses an existing `CircuitBreaker` instance.
839
922
  * - `{ threshold?: number; timeout?: number; }`: Creates a circuit breaker with the specified threshold and timeout.
840
923
  *
@@ -857,11 +940,11 @@ function createCircuitBreaker(opt, injector) {
857
940
  return createNeverBrokenCircuitBreaker();
858
941
  if (typeof opt === 'object' && 'isClosed' in opt)
859
942
  return opt;
860
- const { treshold, timeout, shouldFail, shouldFailForever } = {
943
+ const { threshold, timeout, shouldFail, shouldFailForever } = {
861
944
  ...injectCircuitBreakerOptions(injector),
862
- ...opt,
945
+ ...normalizeThreshold(opt),
863
946
  };
864
- return internalCeateCircuitBreaker(treshold, timeout, shouldFail, shouldFailForever);
947
+ return internalCeateCircuitBreaker(threshold, timeout, shouldFail, shouldFailForever);
865
948
  }
866
949
 
867
950
  // Heavily inspired by: https://dev.to/kasual1/request-deduplication-in-angular-3pd8
@@ -1240,13 +1323,16 @@ function refresh(resource, destroyRef, refresh) {
1240
1323
  }
1241
1324
 
1242
1325
  // Retry on error, if number is provided it will retry that many times with exponential backoff, otherwise it will use the options provided
1243
- function retryOnError(res, opt) {
1326
+ function retryOnError(res, opt, onError) {
1244
1327
  const max = opt ? (typeof opt === 'number' ? opt : (opt.max ?? 0)) : 0;
1245
1328
  const backoff = typeof opt === 'object' ? (opt.backoff ?? 1000) : 1000;
1246
1329
  let retries = 0;
1247
1330
  let timeout;
1248
- const onError = () => {
1249
- if (retries >= max)
1331
+ const handleError = () => {
1332
+ const err = untracked(res.error);
1333
+ const isFinal = retries >= max;
1334
+ onError?.(err, retries, isFinal);
1335
+ if (isFinal)
1250
1336
  return;
1251
1337
  retries++;
1252
1338
  if (timeout)
@@ -1261,7 +1347,7 @@ function retryOnError(res, opt) {
1261
1347
  const ref = effect(() => {
1262
1348
  switch (res.status()) {
1263
1349
  case 'error':
1264
- return onError();
1350
+ return handleError();
1265
1351
  case 'resolved':
1266
1352
  return onSuccess();
1267
1353
  }
@@ -1343,10 +1429,20 @@ function queryResource(request, options) {
1343
1429
  const eq = options?.triggerOnSameRequest
1344
1430
  ? undefined
1345
1431
  : createEqualRequest(options?.equal);
1432
+ const rawRequest = computed(() => request() ?? undefined, ...(ngDevMode ? [{ debugName: "rawRequest" }] : []));
1433
+ const disabledReason = computed(() => {
1434
+ if (!networkAvailable())
1435
+ return 'offline';
1436
+ if (cb.isOpen())
1437
+ return 'circuit-open';
1438
+ if (!rawRequest())
1439
+ return 'no-request';
1440
+ return null;
1441
+ }, ...(ngDevMode ? [{ debugName: "disabledReason" }] : []));
1346
1442
  const stableRequest = computed(() => {
1347
- if (!networkAvailable() || cb.isOpen())
1443
+ if (disabledReason() !== null)
1348
1444
  return undefined;
1349
- const req = request();
1445
+ const req = rawRequest();
1350
1446
  if (!req)
1351
1447
  return undefined;
1352
1448
  if (typeof req === 'string')
@@ -1444,7 +1540,7 @@ function queryResource(request, options) {
1444
1540
  },
1445
1541
  }]));
1446
1542
  resource = refresh(resource, destroyRef, options?.refresh);
1447
- resource = retryOnError(resource, options?.retry);
1543
+ resource = retryOnError(resource, options?.retry, options?.onError);
1448
1544
  resource = persistResourceValues(resource, options?.keepPrevious, options?.equal);
1449
1545
  const value = options?.cache
1450
1546
  ? toWritable(computed(() => {
@@ -1452,20 +1548,6 @@ function queryResource(request, options) {
1452
1548
  return cacheEntry()?.value ?? resource.value();
1453
1549
  }), resource.value.set, resource.value.update)
1454
1550
  : resource.value;
1455
- const onError = options?.onError; // Put in own variable to ensure value remains even if options are somehow mutated in-line
1456
- if (onError) {
1457
- const onErrorRef = effect(() => {
1458
- const err = resource.error();
1459
- if (err)
1460
- onError(err);
1461
- }, ...(ngDevMode ? [{ debugName: "onErrorRef" }] : []));
1462
- // cleanup on manual destroy, I'm comfortable setting these props in-line as we have yet to 'release' the object out of this lexical scope
1463
- const destroyRest = resource.destroy;
1464
- resource.destroy = () => {
1465
- onErrorRef.destroy();
1466
- destroyRest();
1467
- };
1468
- }
1469
1551
  // iterate circuit breaker state, is effect as a computed would cause a circular dependency (resource -> cb -> resource)
1470
1552
  const cbEffectRef = effect(() => {
1471
1553
  const status = resource.status();
@@ -1497,7 +1579,8 @@ function queryResource(request, options) {
1497
1579
  update,
1498
1580
  statusCode: linkedSignal(resource.statusCode),
1499
1581
  headers: linkedSignal(resource.headers),
1500
- disabled: computed(() => cb.isOpen() || stableRequest() === undefined),
1582
+ disabled: computed(() => disabledReason() !== null),
1583
+ disabledReason,
1501
1584
  reload: () => {
1502
1585
  cb.halfOpen(); // open the circuit for manual reload
1503
1586
  return resource.reload();