@mmstack/resource 19.3.1 → 19.3.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);
739
783
  const failureCount = signal(0);
784
+ const failedForever = signal(false);
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
  });
@@ -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
  });
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
@@ -1234,13 +1317,16 @@ function refresh(resource, destroyRef, refresh) {
1234
1317
  }
1235
1318
 
1236
1319
  // Retry on error, if number is provided it will retry that many times with exponential backoff, otherwise it will use the options provided
1237
- function retryOnError(res, opt) {
1320
+ function retryOnError(res, opt, onError) {
1238
1321
  const max = opt ? (typeof opt === 'number' ? opt : (opt.max ?? 0)) : 0;
1239
1322
  const backoff = typeof opt === 'object' ? (opt.backoff ?? 1000) : 1000;
1240
1323
  let retries = 0;
1241
1324
  let timeout;
1242
- const onError = () => {
1243
- if (retries >= max)
1325
+ const handleError = () => {
1326
+ const err = untracked(res.error);
1327
+ const isFinal = retries >= max;
1328
+ onError?.(err, retries, isFinal);
1329
+ if (isFinal)
1244
1330
  return;
1245
1331
  retries++;
1246
1332
  if (timeout)
@@ -1255,7 +1341,7 @@ function retryOnError(res, opt) {
1255
1341
  const ref = effect(() => {
1256
1342
  switch (res.status()) {
1257
1343
  case ResourceStatus.Error:
1258
- return onError();
1344
+ return handleError();
1259
1345
  case ResourceStatus.Resolved:
1260
1346
  return onSuccess();
1261
1347
  }
@@ -1337,10 +1423,20 @@ function queryResource(request, options) {
1337
1423
  const eq = options?.triggerOnSameRequest
1338
1424
  ? undefined
1339
1425
  : createEqualRequest(options?.equal);
1426
+ const rawRequest = computed(() => request() ?? undefined);
1427
+ const disabledReason = computed(() => {
1428
+ if (!networkAvailable())
1429
+ return 'offline';
1430
+ if (cb.isOpen())
1431
+ return 'circuit-open';
1432
+ if (!rawRequest())
1433
+ return 'no-request';
1434
+ return null;
1435
+ });
1340
1436
  const stableRequest = computed(() => {
1341
- if (!networkAvailable() || cb.isOpen())
1437
+ if (disabledReason() !== null)
1342
1438
  return undefined;
1343
- const req = request();
1439
+ const req = rawRequest();
1344
1440
  if (!req)
1345
1441
  return undefined;
1346
1442
  if (typeof req === 'string')
@@ -1416,7 +1512,7 @@ function queryResource(request, options) {
1416
1512
  },
1417
1513
  });
1418
1514
  resource = refresh(resource, destroyRef, options?.refresh);
1419
- resource = retryOnError(resource, options?.retry);
1515
+ resource = retryOnError(resource, options?.retry, options?.onError);
1420
1516
  resource = persistResourceValues(resource, options?.keepPrevious, options?.equal);
1421
1517
  const value = options?.cache
1422
1518
  ? toWritable(computed(() => {
@@ -1424,20 +1520,6 @@ function queryResource(request, options) {
1424
1520
  return cacheEntry()?.value ?? resource.value();
1425
1521
  }), resource.value.set, resource.value.update)
1426
1522
  : resource.value;
1427
- const onError = options?.onError; // Put in own variable to ensure value remains even if options are somehow mutated in-line
1428
- if (onError) {
1429
- const onErrorRef = effect(() => {
1430
- const err = resource.error();
1431
- if (err)
1432
- onError(err);
1433
- });
1434
- // 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
1435
- const destroyRest = resource.destroy;
1436
- resource.destroy = () => {
1437
- onErrorRef.destroy();
1438
- destroyRest();
1439
- };
1440
- }
1441
1523
  // iterate circuit breaker state, is effect as a computed would cause a circular dependency (resource -> cb -> resource)
1442
1524
  const cbEffectRef = effect(() => {
1443
1525
  const status = resource.status();
@@ -1469,7 +1551,8 @@ function queryResource(request, options) {
1469
1551
  update,
1470
1552
  statusCode: linkedSignal(resource.statusCode),
1471
1553
  headers: linkedSignal(resource.headers),
1472
- disabled: computed(() => cb.isOpen() || stableRequest() === undefined),
1554
+ disabled: computed(() => disabledReason() !== null),
1555
+ disabledReason,
1473
1556
  reload: () => {
1474
1557
  cb.halfOpen(); // open the circuit for manual reload
1475
1558
  return resource.reload();