@mmstack/resource 21.1.1 → 21.2.0

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.
@@ -1,4 +1,4 @@
1
- import { HttpHeaders, HttpResponse, HttpContextToken, HttpContext, HttpParams, httpResource, HttpClient } from '@angular/common/http';
1
+ import { HttpHeaders, HttpResponse, HttpParams, HttpContextToken, HttpContext, httpResource, HttpClient } from '@angular/common/http';
2
2
  import * as i0 from '@angular/core';
3
3
  import { isDevMode, untracked, computed, InjectionToken, inject, signal, effect, Injector, linkedSignal, Injectable, DestroyRef } from '@angular/core';
4
4
  import { mutable, toWritable, sensor, nestedEffect } from '@mmstack/primitives';
@@ -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') {
@@ -239,7 +245,7 @@ class Cache {
239
245
  }
240
246
  /** @internal */
241
247
  getInternal(key) {
242
- const keySignal = computed(() => key(), ...(ngDevMode ? [{ debugName: "keySignal" }] : []));
248
+ const keySignal = computed(() => key(), ...(ngDevMode ? [{ debugName: "keySignal" }] : /* istanbul ignore next */ []));
243
249
  return computed(() => {
244
250
  const key = keySignal();
245
251
  if (!key)
@@ -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)
@@ -520,6 +551,173 @@ function injectQueryCache(injector) {
520
551
  return cache;
521
552
  }
522
553
 
554
+ /**
555
+ * Returns `true` for any object-like value whose own enumerable keys should
556
+ * be sorted for stable hashing. Excludes arrays (positional), `Date`
557
+ * (handled by `toJSON`), `Map`/`Set` (handled explicitly), and binary types
558
+ * (`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`/typed arrays — these
559
+ * should be branched on before reaching `hash()`, typically by `hashRequest`).
560
+ *
561
+ * Plain objects, class instances, and `Object.create(null)` all qualify.
562
+ */
563
+ function isHashableObject(value) {
564
+ if (value === null || typeof value !== 'object')
565
+ return false;
566
+ if (Array.isArray(value))
567
+ return false;
568
+ if (value instanceof Date)
569
+ return false;
570
+ if (value instanceof Map)
571
+ return false;
572
+ if (value instanceof Set)
573
+ return false;
574
+ if (typeof Blob !== 'undefined' && value instanceof Blob)
575
+ return false;
576
+ if (typeof FormData !== 'undefined' && value instanceof FormData)
577
+ return false;
578
+ if (typeof URLSearchParams !== 'undefined' &&
579
+ value instanceof URLSearchParams)
580
+ return false;
581
+ if (value instanceof ArrayBuffer)
582
+ return false;
583
+ if (ArrayBuffer.isView(value))
584
+ return false;
585
+ return true;
586
+ }
587
+ function sortKeys(val) {
588
+ return Object.keys(val)
589
+ .toSorted()
590
+ .reduce((result, key) => {
591
+ result[key] = val[key];
592
+ return result;
593
+ }, {});
594
+ }
595
+ /**
596
+ * Internal helper to generate a stable JSON string from an array.
597
+ * - Object-like values (plain, class instances, null-proto) get their own
598
+ * enumerable keys sorted alphabetically.
599
+ * - `Map` → marker object with sorted entries (sorted by `JSON.stringify(key)`).
600
+ * - `Set` → marker object with sorted values (sorted by `JSON.stringify(value)`).
601
+ * - Arrays preserve order. `Date` serializes via `toJSON`.
602
+ *
603
+ * @internal
604
+ */
605
+ function hashKey(queryKey) {
606
+ return JSON.stringify(queryKey, (_, val) => {
607
+ if (val instanceof Map) {
608
+ // Schwartzian: compute each entry's sort key (recursive hash of the
609
+ // Map key) once, then sort by the cheap string compare.
610
+ const entries = [...val.entries()]
611
+ .map((e) => [hash(e[0]), e])
612
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
613
+ .map(([, e]) => e);
614
+ return { __map__: entries };
615
+ }
616
+ if (val instanceof Set) {
617
+ const values = [...val]
618
+ .map((v) => [hash(v), v])
619
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
620
+ .map(([, v]) => v);
621
+ return { __set__: values };
622
+ }
623
+ if (isHashableObject(val))
624
+ return sortKeys(val);
625
+ return val;
626
+ });
627
+ }
628
+ /**
629
+ * Generates a stable, unique string hash from one or more arguments.
630
+ * Useful for creating cache keys or identifiers where object key order shouldn't matter.
631
+ *
632
+ * How it works:
633
+ * - Object-like values (plain objects, class instances, `Object.create(null)`) have
634
+ * their own enumerable keys sorted alphabetically before hashing. This ensures
635
+ * `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same hash.
636
+ * - `Map` and `Set` are serialized via stable, sorted markers (`__map__` / `__set__`).
637
+ * - Arrays preserve positional order; `Date` uses its ISO string via `toJSON`.
638
+ *
639
+ * @param {...unknown} args Values to include in the hash.
640
+ * @returns A stable string hash representing the input arguments.
641
+ * @example
642
+ * hash('posts', 10);
643
+ * // => '["posts",10]'
644
+ *
645
+ * hash({ a: 1, b: 2 }) === hash({ b: 2, a: 1 }); // true
646
+ *
647
+ * hash(new Map([['a', 1]])) === hash(new Map([['a', 1]])); // true
648
+ *
649
+ * // Be mindful of values JSON.stringify cannot handle (functions, undefined, Symbols)
650
+ * // hash('a', undefined, function() {}) => '["a",null,null]'
651
+ */
652
+ function hash(...args) {
653
+ return hashKey(args);
654
+ }
655
+
656
+ function normalizeParams(params) {
657
+ const p = params instanceof HttpParams
658
+ ? params
659
+ : new HttpParams({ fromObject: params });
660
+ return p
661
+ .keys()
662
+ .toSorted()
663
+ .map((key) => {
664
+ const encodedKey = encodeURIComponent(key);
665
+ return (p.getAll(key) ?? [])
666
+ .map((v) => `${encodedKey}=${encodeURIComponent(v)}`)
667
+ .join('&');
668
+ })
669
+ .join('&');
670
+ }
671
+ function hashBody(body) {
672
+ // File extends Blob — must check File first
673
+ if (typeof File !== 'undefined' && body instanceof File) {
674
+ return `File:${body.name}:${body.type}:${body.size}:${body.lastModified}`;
675
+ }
676
+ if (typeof Blob !== 'undefined' && body instanceof Blob) {
677
+ return `Blob:${body.type}:${body.size}`;
678
+ }
679
+ if (typeof FormData !== 'undefined' && body instanceof FormData) {
680
+ const entries = [];
681
+ body.forEach((value, key) => {
682
+ entries.push([key, hashBody(value)]);
683
+ });
684
+ entries.sort(([ak, av], [bk, bv]) => ak.localeCompare(bk) || av.localeCompare(bv));
685
+ return `FormData:${entries.map(([k, v]) => `${k}=${v}`).join('&')}`;
686
+ }
687
+ if (typeof URLSearchParams !== 'undefined' &&
688
+ body instanceof URLSearchParams) {
689
+ const sp = new URLSearchParams(body);
690
+ sp.sort();
691
+ return `URLSearchParams:${sp.toString()}`;
692
+ }
693
+ if (body instanceof ArrayBuffer) {
694
+ return `ArrayBuffer:${body.byteLength}`;
695
+ }
696
+ if (ArrayBuffer.isView(body)) {
697
+ return `${body.constructor.name}:${body.byteLength}`;
698
+ }
699
+ return hash(body);
700
+ }
701
+ /**
702
+ * Builds a stable cache/dedupe key from an HTTP request shape (accepts both
703
+ * `HttpRequest` and `HttpResourceRequest`).
704
+ *
705
+ * Key composition: `${method}:${url}:${responseType}[:${params}][:${body}]`
706
+ * - `method` defaults to `'GET'`, `responseType` to `'json'` (Angular defaults).
707
+ * - Query params are sorted alphabetically and URL-encoded for stability.
708
+ * - Body hashing handles `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`
709
+ * and typed arrays explicitly; everything else flows through key-sorted
710
+ * `JSON.stringify` via `hash()`.
711
+ */
712
+ function hashRequest(req) {
713
+ const method = req.method ?? 'GET';
714
+ const responseType = req.responseType ?? 'json';
715
+ const base = `${method}:${req.url}:${responseType}`;
716
+ const params = req.params ? `:${normalizeParams(req.params)}` : '';
717
+ const body = req.body != null ? `:${hashBody(req.body)}` : '';
718
+ return base + params + body;
719
+ }
720
+
523
721
  const CACHE_CONTEXT = new HttpContextToken(() => ({
524
722
  cache: false,
525
723
  }));
@@ -663,7 +861,7 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
663
861
  const opt = getCacheContext(req.context);
664
862
  if (!opt.cache)
665
863
  return next(req);
666
- const key = opt.key ?? req.urlWithParams;
864
+ const key = opt.key ?? hashRequest(req);
667
865
  const entry = cache.getUntracked(key); // null if expired or not found
668
866
  // If the entry is not stale, return it
669
867
  if (entry && !entry.isStale)
@@ -683,7 +881,9 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
683
881
  });
684
882
  }
685
883
  return next(req).pipe(tap((event) => {
686
- if (event instanceof HttpResponse && event.ok) {
884
+ if (!(event instanceof HttpResponse))
885
+ return;
886
+ if (event.ok) {
687
887
  const cacheControl = parseCacheControlHeader(event);
688
888
  if (cacheControl.noStore && !opt.ignoreCacheControl)
689
889
  return;
@@ -702,6 +902,17 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
702
902
  })
703
903
  : event;
704
904
  cache.store(key, parsedResponse, staleTime, ttl, opt.persist);
905
+ return;
906
+ }
907
+ // 304 → server confirmed our cached entry is still valid. Re-stamp the
908
+ // existing entry so subsequent reads within the new freshness window
909
+ // don't trigger another revalidation round-trip.
910
+ if (event.status === 304 && entry) {
911
+ const cacheControl = parseCacheControlHeader(event);
912
+ const { staleTime, ttl } = opt.ignoreCacheControl
913
+ ? opt
914
+ : resolveTimings(cacheControl, opt.staleTime, opt.ttl);
915
+ cache.store(key, entry.value, staleTime, ttl, opt.persist);
705
916
  }
706
917
  }), map((event) => {
707
918
  // handle 304 responses due to eTag/last-modified
@@ -729,22 +940,23 @@ function catchValueError(resource, fallback) {
729
940
 
730
941
  /** @internal */
731
942
  const DEFAULT_OPTIONS = {
732
- treshold: 5,
943
+ threshold: 5,
733
944
  timeout: 30000,
734
945
  shouldFail: () => true,
735
946
  shouldFailForever: () => false,
736
947
  };
737
948
  /** @internal */
738
- function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldFail = () => true, shouldFailForever = () => false) {
739
- const halfOpen = signal(false, ...(ngDevMode ? [{ debugName: "halfOpen" }] : []));
740
- const failureCount = signal(0, ...(ngDevMode ? [{ debugName: "failureCount" }] : []));
949
+ function internalCeateCircuitBreaker(threshold = 5, resetTimeout = 30000, shouldFail = () => true, shouldFailForever = () => false) {
950
+ const halfOpen = signal(false, ...(ngDevMode ? [{ debugName: "halfOpen" }] : /* istanbul ignore next */ []));
951
+ const failureCount = signal(0, ...(ngDevMode ? [{ debugName: "failureCount" }] : /* istanbul ignore next */ []));
952
+ const failedForever = signal(false, ...(ngDevMode ? [{ debugName: "failedForever" }] : /* istanbul ignore next */ []));
741
953
  const status = computed(() => {
742
- if (failureCount() >= treshold)
954
+ if (failedForever() || failureCount() >= threshold)
743
955
  return 'OPEN';
744
956
  return halfOpen() ? 'HALF_OPEN' : 'CLOSED';
745
- }, ...(ngDevMode ? [{ debugName: "status" }] : []));
746
- const isClosed = computed(() => status() !== 'OPEN', ...(ngDevMode ? [{ debugName: "isClosed" }] : []));
747
- const isOpen = computed(() => status() !== 'CLOSED', ...(ngDevMode ? [{ debugName: "isOpen" }] : []));
957
+ }, ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
958
+ const isClosed = computed(() => status() !== 'OPEN', ...(ngDevMode ? [{ debugName: "isClosed" }] : /* istanbul ignore next */ []));
959
+ const isOpen = computed(() => status() !== 'CLOSED', ...(ngDevMode ? [{ debugName: "isOpen" }] : /* istanbul ignore next */ []));
748
960
  const success = () => {
749
961
  failureCount.set(0);
750
962
  halfOpen.set(false);
@@ -753,30 +965,26 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldF
753
965
  if (!untracked(isOpen))
754
966
  return;
755
967
  halfOpen.set(true);
756
- failureCount.set(treshold - 1);
968
+ failureCount.set(threshold - 1);
757
969
  };
758
- let failForeverResetId = null;
970
+ // Auto-probe effect: schedules a half-open retry after `resetTimeout` whenever
971
+ // the breaker is open, *unless* we've been failed forever (in which case only
972
+ // hardReset() can recover).
759
973
  const effectRef = effect((cleanup) => {
760
- if (!isOpen())
974
+ if (!isOpen() || failedForever())
761
975
  return;
762
976
  const timeout = setTimeout(tryOnce, resetTimeout);
763
- failForeverResetId = timeout;
764
977
  return cleanup(() => {
765
978
  clearTimeout(timeout);
766
- failForeverResetId = null;
767
979
  });
768
- }, ...(ngDevMode ? [{ debugName: "effectRef" }] : []));
980
+ }, ...(ngDevMode ? [{ debugName: "effectRef" }] : /* istanbul ignore next */ []));
769
981
  const failInternal = () => {
770
982
  failureCount.set(failureCount() + 1);
771
983
  halfOpen.set(false);
772
984
  };
773
985
  const failForever = () => {
774
- if (failForeverResetId)
775
- clearTimeout(failForeverResetId);
776
- effectRef.destroy();
777
- failureCount.set(Infinity);
986
+ failedForever.set(true);
778
987
  halfOpen.set(false);
779
- return;
780
988
  };
781
989
  const fail = (err) => {
782
990
  if (shouldFailForever(err))
@@ -785,6 +993,11 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldF
785
993
  return failInternal();
786
994
  // If the error does not trigger a failure, we do nothing.
787
995
  };
996
+ const hardReset = () => {
997
+ failedForever.set(false);
998
+ failureCount.set(0);
999
+ halfOpen.set(false);
1000
+ };
788
1001
  return {
789
1002
  status,
790
1003
  isClosed,
@@ -792,6 +1005,7 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldF
792
1005
  fail,
793
1006
  success,
794
1007
  halfOpen: tryOnce,
1008
+ hardReset,
795
1009
  destroy: () => effectRef.destroy(),
796
1010
  };
797
1011
  }
@@ -810,18 +1024,43 @@ function createNeverBrokenCircuitBreaker() {
810
1024
  halfOpen: () => {
811
1025
  // noop
812
1026
  },
1027
+ hardReset: () => {
1028
+ // noop
1029
+ },
813
1030
  destroy: () => {
814
1031
  // noop
815
1032
  },
816
1033
  };
817
1034
  }
818
1035
  const CB_DEFAULT_OPTIONS = new InjectionToken('MMSTACK_CIRCUIT_BREAKER_DEFAULT_OPTIONS');
1036
+ /**
1037
+ * Provides application-wide default options for {@link createCircuitBreaker}.
1038
+ * Any `createCircuitBreaker()` call without explicit options (or with only
1039
+ * partial options) merges these defaults in, so you can centralize threshold /
1040
+ * timeout / failure-classifier behavior in one place.
1041
+ *
1042
+ * Per-call options always win over the provided defaults.
1043
+ *
1044
+ * @example
1045
+ * ```ts
1046
+ * bootstrapApplication(AppComponent, {
1047
+ * providers: [
1048
+ * provideCircuitBreakerDefaultOptions({
1049
+ * threshold: 10,
1050
+ * timeout: 60_000,
1051
+ * shouldFailForever: (err) =>
1052
+ * err instanceof HttpErrorResponse && [401, 403].includes(err.status),
1053
+ * }),
1054
+ * ],
1055
+ * });
1056
+ * ```
1057
+ */
819
1058
  function provideCircuitBreakerDefaultOptions(options) {
820
1059
  return {
821
1060
  provide: CB_DEFAULT_OPTIONS,
822
1061
  useValue: {
823
1062
  ...DEFAULT_OPTIONS,
824
- ...options,
1063
+ ...normalizeThreshold(options),
825
1064
  },
826
1065
  };
827
1066
  }
@@ -830,12 +1069,23 @@ function injectCircuitBreakerOptions(injector = inject(Injector)) {
830
1069
  optional: true,
831
1070
  });
832
1071
  }
1072
+ /** @internal — strips the deprecated `treshold` field and folds it into `threshold` */
1073
+ function normalizeThreshold(opt) {
1074
+ if (!opt || typeof opt !== 'object' || 'isClosed' in opt)
1075
+ return {};
1076
+ const { treshold, threshold, ...rest } = opt;
1077
+ return {
1078
+ ...rest,
1079
+ threshold: threshold ?? treshold,
1080
+ };
1081
+ }
833
1082
  /**
834
1083
  * Creates a circuit breaker instance.
835
1084
  *
836
1085
  * @param options - Configuration options for the circuit breaker. Can be:
837
- * - `undefined`: Creates a "no-op" circuit breaker that is always open (never trips).
838
- * - `true`: Creates a circuit breaker with default settings (threshold: 5, timeout: 30000ms).
1086
+ * - `undefined`: Uses defaults (threshold: 5, timeout: 30000ms) or provided defaults via {@link provideCircuitBreakerDefaultOptions}.
1087
+ * - `false`: Creates a "no-op" circuit breaker that is always closed (never trips).
1088
+ * - `true`: Creates a circuit breaker with default settings.
839
1089
  * - `CircuitBreaker`: Reuses an existing `CircuitBreaker` instance.
840
1090
  * - `{ threshold?: number; timeout?: number; }`: Creates a circuit breaker with the specified threshold and timeout.
841
1091
  *
@@ -858,11 +1108,11 @@ function createCircuitBreaker(opt, injector) {
858
1108
  return createNeverBrokenCircuitBreaker();
859
1109
  if (typeof opt === 'object' && 'isClosed' in opt)
860
1110
  return opt;
861
- const { treshold, timeout, shouldFail, shouldFailForever } = {
1111
+ const { threshold, timeout, shouldFail, shouldFailForever } = {
862
1112
  ...injectCircuitBreakerOptions(injector),
863
- ...opt,
1113
+ ...normalizeThreshold(opt),
864
1114
  };
865
- return internalCeateCircuitBreaker(treshold, timeout, shouldFail, shouldFailForever);
1115
+ return internalCeateCircuitBreaker(threshold, timeout, shouldFail, shouldFailForever);
866
1116
  }
867
1117
 
868
1118
  // Heavily inspired by: https://dev.to/kasual1/request-deduplication-in-angular-3pd8
@@ -894,6 +1144,9 @@ function noDedupe(ctx = new HttpContext()) {
894
1144
  *
895
1145
  * @param allowed - An array of HTTP methods for which deduplication should be enabled.
896
1146
  * Defaults to `['GET', 'DELETE', 'HEAD', 'OPTIONS']`.
1147
+ * @param keyFn - Optional function to compute the dedupe key from a request.
1148
+ * Defaults to `hashRequest`, which includes method, URL,
1149
+ * response type, params, and body.
897
1150
  *
898
1151
  * @returns An `HttpInterceptorFn` that implements the request deduplication logic.
899
1152
  *
@@ -917,97 +1170,22 @@ function noDedupe(ctx = new HttpContext()) {
917
1170
  * ],
918
1171
  * };
919
1172
  */
920
- function createDedupeRequestsInterceptor(allowed = ['GET', 'DELETE', 'HEAD', 'OPTIONS']) {
1173
+ function createDedupeRequestsInterceptor(allowed = ['GET', 'DELETE', 'HEAD', 'OPTIONS'], keyFn = hashRequest) {
921
1174
  const inFlight = new Map();
922
1175
  const DEDUPE_METHODS = new Set(allowed);
923
1176
  return (req, next) => {
924
1177
  if (!DEDUPE_METHODS.has(req.method) || req.context.get(NO_DEDUPE))
925
1178
  return next(req);
926
- const found = inFlight.get(req.urlWithParams);
1179
+ const key = keyFn(req);
1180
+ const found = inFlight.get(key);
927
1181
  if (found)
928
1182
  return found;
929
- const request = next(req).pipe(finalize(() => inFlight.delete(req.urlWithParams)), shareReplay());
930
- inFlight.set(req.urlWithParams, request);
1183
+ const request = next(req).pipe(finalize(() => inFlight.delete(key)), shareReplay({ bufferSize: 1, refCount: true }));
1184
+ inFlight.set(key, request);
931
1185
  return request;
932
1186
  };
933
1187
  }
934
1188
 
935
- /**
936
- * Checks if `value` is a plain JavaScript object (e.g., `{}` or `new Object()`).
937
- * Distinguishes from arrays, null, and class instances. Acts as a type predicate,
938
- * narrowing `value` to `UnknownObject` if `true`.
939
- *
940
- * @param value The value to check.
941
- * @returns {value is UnknownObject} `true` if `value` is a plain object, otherwise `false`.
942
- * @example
943
- * isPlainObject({}) // => true
944
- * isPlainObject([]) // => false
945
- * isPlainObject(null) // => false
946
- * isPlainObject(new Date()) // => false
947
- */
948
- function isPlainObject(value) {
949
- if (value === null || typeof value !== 'object')
950
- return false;
951
- const proto = Object.getPrototypeOf(value);
952
- if (proto === null)
953
- return false; // remove Object.create(null);
954
- return proto === Object.prototype;
955
- }
956
- /**
957
- * Internal helper to generate a stable JSON string from an array.
958
- * Sorts keys of plain objects within the array alphabetically before serialization
959
- * to ensure hash stability regardless of key order.
960
- *
961
- * @param queryKey The array of values to serialize.
962
- * @returns A stable JSON string representation.
963
- * @internal
964
- */
965
- function hashKey(queryKey) {
966
- return JSON.stringify(queryKey, (_, val) => isPlainObject(val)
967
- ? Object.keys(val)
968
- .toSorted()
969
- .reduce((result, key) => {
970
- result[key] = val[key];
971
- return result;
972
- }, {})
973
- : val);
974
- }
975
- /**
976
- * Generates a stable, unique string hash from one or more arguments.
977
- * Useful for creating cache keys or identifiers where object key order shouldn't matter.
978
- *
979
- * How it works:
980
- * - Plain objects within the arguments have their keys sorted alphabetically before hashing.
981
- * This ensures that `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same hash.
982
- * - Uses `JSON.stringify` internally with custom sorting for plain objects via `hashKey`.
983
- * - Non-plain objects (arrays, Dates, etc.) and primitives are serialized naturally.
984
- *
985
- * @param {...unknown} args Values to include in the hash.
986
- * @returns A stable string hash representing the input arguments.
987
- * @example
988
- * const userQuery = (id: number) => ['user', { id, timestamp: Date.now() }];
989
- *
990
- * const obj1 = { a: 1, b: 2 };
991
- * const obj2 = { b: 2, a: 1 }; // Same keys/values, different order
992
- *
993
- * hash('posts', 10);
994
- * // => '["posts",10]'
995
- *
996
- * hash('config', obj1);
997
- * // => '["config",{"a":1,"b":2}]'
998
- *
999
- * hash('config', obj2);
1000
- * // => '["config",{"a":1,"b":2}]' (Same as above due to key sorting)
1001
- *
1002
- * hash(['todos', { status: 'done', owner: obj1 }]);
1003
- * // => '[["todos",{"owner":{"a":1,"b":2},"status":"done"}]]'
1004
- *
1005
- * // Be mindful of values JSON.stringify cannot handle (functions, undefined, Symbols)
1006
- * // hash('a', undefined, function() {}) => '["a",null,null]'
1007
- */
1008
- function hash(...args) {
1009
- return hashKey(args);
1010
- }
1011
1189
  function equalTransferCache(a, b) {
1012
1190
  if (!a && !b)
1013
1191
  return true;
@@ -1179,7 +1357,7 @@ function hasSlowConnection() {
1179
1357
 
1180
1358
  function persist(src, equal) {
1181
1359
  // linkedSignal allows us to access previous source value
1182
- const persisted = linkedSignal({ ...(ngDevMode ? { debugName: "persisted" } : {}), source: () => src(),
1360
+ const persisted = linkedSignal({ ...(ngDevMode ? { debugName: "persisted" } : /* istanbul ignore next */ {}), source: () => src(),
1183
1361
  computation: (next, prev) => {
1184
1362
  if (next === undefined && prev !== undefined)
1185
1363
  return prev.value;
@@ -1233,13 +1411,16 @@ function refresh(resource, destroyRef, refresh) {
1233
1411
  }
1234
1412
 
1235
1413
  // Retry on error, if number is provided it will retry that many times with exponential backoff, otherwise it will use the options provided
1236
- function retryOnError(res, opt) {
1414
+ function retryOnError(res, opt, onError) {
1237
1415
  const max = opt ? (typeof opt === 'number' ? opt : (opt.max ?? 0)) : 0;
1238
1416
  const backoff = typeof opt === 'object' ? (opt.backoff ?? 1000) : 1000;
1239
1417
  let retries = 0;
1240
1418
  let timeout;
1241
- const onError = () => {
1242
- if (retries >= max)
1419
+ const handleError = () => {
1420
+ const err = untracked(res.error);
1421
+ const isFinal = retries >= max;
1422
+ onError?.(err, retries, isFinal);
1423
+ if (isFinal)
1243
1424
  return;
1244
1425
  retries++;
1245
1426
  if (timeout)
@@ -1254,11 +1435,11 @@ function retryOnError(res, opt) {
1254
1435
  const ref = effect(() => {
1255
1436
  switch (res.status()) {
1256
1437
  case 'error':
1257
- return onError();
1438
+ return handleError();
1258
1439
  case 'resolved':
1259
1440
  return onSuccess();
1260
1441
  }
1261
- }, ...(ngDevMode ? [{ debugName: "ref" }] : []));
1442
+ }, ...(ngDevMode ? [{ debugName: "ref" }] : /* istanbul ignore next */ []));
1262
1443
  return {
1263
1444
  ...res,
1264
1445
  destroy: () => {
@@ -1270,10 +1451,10 @@ function retryOnError(res, opt) {
1270
1451
 
1271
1452
  class ResourceSensors {
1272
1453
  networkStatus = sensor('networkStatus');
1273
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: ResourceSensors, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1274
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: ResourceSensors, providedIn: 'root' });
1454
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ResourceSensors, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1455
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ResourceSensors, providedIn: 'root' });
1275
1456
  }
1276
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: ResourceSensors, decorators: [{
1457
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ResourceSensors, decorators: [{
1277
1458
  type: Injectable,
1278
1459
  args: [{
1279
1460
  providedIn: 'root',
@@ -1302,29 +1483,6 @@ function toResourceObject(res) {
1302
1483
  };
1303
1484
  }
1304
1485
 
1305
- function normalizeParams(params) {
1306
- if (params instanceof HttpParams)
1307
- return params.toString();
1308
- const paramMap = new Map();
1309
- for (const [key, value] of Object.entries(params)) {
1310
- if (Array.isArray(value)) {
1311
- paramMap.set(key, value.map(encodeURIComponent).join(','));
1312
- }
1313
- else {
1314
- paramMap.set(key, encodeURIComponent(value.toString()));
1315
- }
1316
- }
1317
- return Array.from(paramMap.entries())
1318
- .sort(([a], [b]) => a.localeCompare(b))
1319
- .map(([key, value]) => `${key}=${value}`)
1320
- .join('&');
1321
- }
1322
- function urlWithParams(req) {
1323
- if (!req.params)
1324
- return req.url;
1325
- return `${req.url}?${normalizeParams(req.params)}`;
1326
- }
1327
-
1328
1486
  function queryResource(request, options) {
1329
1487
  const cache = injectQueryCache(options?.injector);
1330
1488
  const destroyRef = options?.injector
@@ -1337,23 +1495,33 @@ function queryResource(request, options) {
1337
1495
  const eq = options?.triggerOnSameRequest
1338
1496
  ? undefined
1339
1497
  : createEqualRequest(options?.equal);
1498
+ const rawRequest = computed(() => request() ?? undefined, ...(ngDevMode ? [{ debugName: "rawRequest" }] : /* istanbul ignore next */ []));
1499
+ const disabledReason = computed(() => {
1500
+ if (!networkAvailable())
1501
+ return 'offline';
1502
+ if (cb.isOpen())
1503
+ return 'circuit-open';
1504
+ if (!rawRequest())
1505
+ return 'no-request';
1506
+ return null;
1507
+ }, ...(ngDevMode ? [{ debugName: "disabledReason" }] : /* istanbul ignore next */ []));
1340
1508
  const stableRequest = computed(() => {
1341
- if (!networkAvailable() || cb.isOpen())
1509
+ if (disabledReason() !== null)
1342
1510
  return undefined;
1343
- const req = request();
1511
+ const req = rawRequest();
1344
1512
  if (!req)
1345
1513
  return undefined;
1346
1514
  if (typeof req === 'string')
1347
1515
  return { method: 'GET', url: req };
1348
1516
  return req;
1349
- }, { ...(ngDevMode ? { debugName: "stableRequest" } : {}), equal: (a, b) => {
1517
+ }, { ...(ngDevMode ? { debugName: "stableRequest" } : /* istanbul ignore next */ {}), equal: (a, b) => {
1350
1518
  if (eq)
1351
1519
  return eq(a, b);
1352
1520
  return a === b;
1353
1521
  } });
1354
1522
  const hashFn = typeof options?.cache === 'object'
1355
- ? (options.cache.hash ?? urlWithParams)
1356
- : urlWithParams;
1523
+ ? (options.cache.hash ?? hashRequest)
1524
+ : hashRequest;
1357
1525
  const staleTime = typeof options?.cache === 'object' ? options.cache.staleTime : 0;
1358
1526
  const ttl = typeof options?.cache === 'object' ? options.cache.ttl : undefined;
1359
1527
  const cacheKey = computed(() => {
@@ -1361,7 +1529,7 @@ function queryResource(request, options) {
1361
1529
  if (!r)
1362
1530
  return null;
1363
1531
  return hashFn(r);
1364
- }, ...(ngDevMode ? [{ debugName: "cacheKey" }] : []));
1532
+ }, ...(ngDevMode ? [{ debugName: "cacheKey" }] : /* istanbul ignore next */ []));
1365
1533
  const bustBrowserCache = typeof options?.cache === 'object' &&
1366
1534
  options.cache.bustBrowserCache === true;
1367
1535
  const ignoreCacheControl = typeof options?.cache === 'object' &&
@@ -1392,7 +1560,7 @@ function queryResource(request, options) {
1392
1560
  resource = catchValueError(resource, options?.defaultValue);
1393
1561
  // get full HttpResonse from Cache
1394
1562
  const cachedEvent = cache.getEntryOrKey(cacheKey);
1395
- const cacheEntry = linkedSignal({ ...(ngDevMode ? { debugName: "cacheEntry" } : {}), source: () => cachedEvent(),
1563
+ const cacheEntry = linkedSignal({ ...(ngDevMode ? { debugName: "cacheEntry" } : /* istanbul ignore next */ {}), source: () => cachedEvent(),
1396
1564
  computation: (entry, prev) => {
1397
1565
  if (!entry)
1398
1566
  return null;
@@ -1412,7 +1580,7 @@ function queryResource(request, options) {
1412
1580
  };
1413
1581
  } });
1414
1582
  resource = refresh(resource, destroyRef, options?.refresh);
1415
- resource = retryOnError(resource, options?.retry);
1583
+ resource = retryOnError(resource, options?.retry, options?.onError);
1416
1584
  resource = persistResourceValues(resource, options?.keepPrevious, options?.equal);
1417
1585
  const value = options?.cache
1418
1586
  ? toWritable(computed(() => {
@@ -1420,20 +1588,6 @@ function queryResource(request, options) {
1420
1588
  return cacheEntry()?.value ?? resource.value();
1421
1589
  }), resource.value.set, resource.value.update)
1422
1590
  : resource.value;
1423
- const onError = options?.onError; // Put in own variable to ensure value remains even if options are somehow mutated in-line
1424
- if (onError) {
1425
- const onErrorRef = effect(() => {
1426
- const err = resource.error();
1427
- if (err)
1428
- onError(err);
1429
- }, ...(ngDevMode ? [{ debugName: "onErrorRef" }] : []));
1430
- // 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
1431
- const destroyRest = resource.destroy;
1432
- resource.destroy = () => {
1433
- onErrorRef.destroy();
1434
- destroyRest();
1435
- };
1436
- }
1437
1591
  // iterate circuit breaker state, is effect as a computed would cause a circular dependency (resource -> cb -> resource)
1438
1592
  const cbEffectRef = effect(() => {
1439
1593
  const status = resource.status();
@@ -1441,7 +1595,7 @@ function queryResource(request, options) {
1441
1595
  cb.fail(untracked(resource.error));
1442
1596
  else if (status === 'resolved')
1443
1597
  cb.success();
1444
- }, ...(ngDevMode ? [{ debugName: "cbEffectRef" }] : []));
1598
+ }, ...(ngDevMode ? [{ debugName: "cbEffectRef" }] : /* istanbul ignore next */ []));
1445
1599
  const set = (value) => {
1446
1600
  resource.value.set(value);
1447
1601
  const k = untracked(cacheKey);
@@ -1465,7 +1619,8 @@ function queryResource(request, options) {
1465
1619
  update,
1466
1620
  statusCode: linkedSignal(resource.statusCode),
1467
1621
  headers: linkedSignal(resource.headers),
1468
- disabled: computed(() => cb.isOpen() || stableRequest() === undefined),
1622
+ disabled: computed(() => disabledReason() !== null),
1623
+ disabledReason,
1469
1624
  reload: () => {
1470
1625
  cb.halfOpen(); // open the circuit for manual reload
1471
1626
  return resource.reload();
@@ -1528,7 +1683,7 @@ function queryResource(request, options) {
1528
1683
  }
1529
1684
 
1530
1685
  function manualQueryResource(request, options) {
1531
- const trigger = signal({ epoch: 0 }, { ...(ngDevMode ? { debugName: "trigger" } : {}), equal: (a, b) => a.epoch === b.epoch });
1686
+ const trigger = signal({ epoch: 0 }, { ...(ngDevMode ? { debugName: "trigger" } : /* istanbul ignore next */ {}), equal: (a, b) => a.epoch === b.epoch });
1532
1687
  const injector = options?.injector ?? inject(Injector);
1533
1688
  const req = computed(() => {
1534
1689
  const state = trigger();
@@ -1537,7 +1692,7 @@ function manualQueryResource(request, options) {
1537
1692
  if (state.override)
1538
1693
  return state.override;
1539
1694
  return untracked(request);
1540
- }, { ...(ngDevMode ? { debugName: "req" } : {}), equal: () => false });
1695
+ }, { ...(ngDevMode ? { debugName: "req" } : /* istanbul ignore next */ {}), equal: () => false });
1541
1696
  const resource = queryResource(req, options);
1542
1697
  return {
1543
1698
  ...resource,
@@ -1586,14 +1741,14 @@ function mutationResource(request, options = {}) {
1586
1741
  const { onMutate, onError, onSuccess, onSettled, equal, ...rest } = options;
1587
1742
  const requestEqual = createEqualRequest(equal);
1588
1743
  const eq = equal ?? Object.is;
1589
- const next = signal(null, { ...(ngDevMode ? { debugName: "next" } : {}), equal: (a, b) => {
1744
+ const next = signal(null, { ...(ngDevMode ? { debugName: "next" } : /* istanbul ignore next */ {}), equal: (a, b) => {
1590
1745
  if (!a && !b)
1591
1746
  return true;
1592
1747
  if (!a || !b)
1593
1748
  return false;
1594
1749
  return eq(a, b);
1595
1750
  } });
1596
- const queue = signal([], ...(ngDevMode ? [{ debugName: "queue" }] : []));
1751
+ const queue = signal([], ...(ngDevMode ? [{ debugName: "queue" }] : /* istanbul ignore next */ []));
1597
1752
  let ctx = undefined;
1598
1753
  const queueRef = effect(() => {
1599
1754
  const nextInQueue = queue().at(0);
@@ -1603,14 +1758,14 @@ function mutationResource(request, options = {}) {
1603
1758
  const [value, ictx] = nextInQueue;
1604
1759
  ctx = onMutate?.(value, ictx);
1605
1760
  next.set(value);
1606
- }, ...(ngDevMode ? [{ debugName: "queueRef" }] : []));
1761
+ }, ...(ngDevMode ? [{ debugName: "queueRef" }] : /* istanbul ignore next */ []));
1607
1762
  const req = computed(() => {
1608
1763
  const nr = next();
1609
1764
  if (!nr)
1610
1765
  return;
1611
1766
  return request(nr) ?? undefined;
1612
- }, { ...(ngDevMode ? { debugName: "req" } : {}), equal: requestEqual });
1613
- const lastValue = linkedSignal({ ...(ngDevMode ? { debugName: "lastValue" } : {}), source: next,
1767
+ }, { ...(ngDevMode ? { debugName: "req" } : /* istanbul ignore next */ {}), equal: requestEqual });
1768
+ const lastValue = linkedSignal({ ...(ngDevMode ? { debugName: "lastValue" } : /* istanbul ignore next */ {}), source: next,
1614
1769
  computation: (next, prev) => {
1615
1770
  if (next === null && !!prev)
1616
1771
  return prev.value;
@@ -1621,7 +1776,7 @@ function mutationResource(request, options = {}) {
1621
1776
  if (!nr)
1622
1777
  return;
1623
1778
  return request(nr) ?? undefined;
1624
- }, { ...(ngDevMode ? { debugName: "lastValueRequest" } : {}), equal: requestEqual });
1779
+ }, { ...(ngDevMode ? { debugName: "lastValueRequest" } : /* istanbul ignore next */ {}), equal: requestEqual });
1625
1780
  const cb = createCircuitBreaker(options?.circuitBreaker === true
1626
1781
  ? undefined
1627
1782
  : (options?.circuitBreaker ?? false), options?.injector);