@mmstack/resource 20.5.1 → 20.6.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,7 +1,7 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { isDevMode, untracked, computed, InjectionToken, inject, signal, effect, Injector, linkedSignal, Injectable, DestroyRef } from '@angular/core';
3
3
  import { mutable, toWritable, sensor, nestedEffect } from '@mmstack/primitives';
4
- import { HttpHeaders, HttpResponse, HttpContextToken, HttpContext, HttpParams, httpResource, HttpClient } from '@angular/common/http';
4
+ import { HttpHeaders, HttpResponse, HttpParams, HttpContextToken, HttpContext, httpResource, HttpClient } from '@angular/common/http';
5
5
  import { of, tap, map, finalize, shareReplay, interval, firstValueFrom, catchError, combineLatestWith, filter } from 'rxjs';
6
6
  import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
7
7
 
@@ -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)
@@ -519,6 +550,173 @@ function injectQueryCache(injector) {
519
550
  return cache;
520
551
  }
521
552
 
553
+ /**
554
+ * Returns `true` for any object-like value whose own enumerable keys should
555
+ * be sorted for stable hashing. Excludes arrays (positional), `Date`
556
+ * (handled by `toJSON`), `Map`/`Set` (handled explicitly), and binary types
557
+ * (`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`/typed arrays — these
558
+ * should be branched on before reaching `hash()`, typically by `hashRequest`).
559
+ *
560
+ * Plain objects, class instances, and `Object.create(null)` all qualify.
561
+ */
562
+ function isHashableObject(value) {
563
+ if (value === null || typeof value !== 'object')
564
+ return false;
565
+ if (Array.isArray(value))
566
+ return false;
567
+ if (value instanceof Date)
568
+ return false;
569
+ if (value instanceof Map)
570
+ return false;
571
+ if (value instanceof Set)
572
+ return false;
573
+ if (typeof Blob !== 'undefined' && value instanceof Blob)
574
+ return false;
575
+ if (typeof FormData !== 'undefined' && value instanceof FormData)
576
+ return false;
577
+ if (typeof URLSearchParams !== 'undefined' &&
578
+ value instanceof URLSearchParams)
579
+ return false;
580
+ if (value instanceof ArrayBuffer)
581
+ return false;
582
+ if (ArrayBuffer.isView(value))
583
+ return false;
584
+ return true;
585
+ }
586
+ function sortKeys(val) {
587
+ return Object.keys(val)
588
+ .toSorted()
589
+ .reduce((result, key) => {
590
+ result[key] = val[key];
591
+ return result;
592
+ }, {});
593
+ }
594
+ /**
595
+ * Internal helper to generate a stable JSON string from an array.
596
+ * - Object-like values (plain, class instances, null-proto) get their own
597
+ * enumerable keys sorted alphabetically.
598
+ * - `Map` → marker object with sorted entries (sorted by `JSON.stringify(key)`).
599
+ * - `Set` → marker object with sorted values (sorted by `JSON.stringify(value)`).
600
+ * - Arrays preserve order. `Date` serializes via `toJSON`.
601
+ *
602
+ * @internal
603
+ */
604
+ function hashKey(queryKey) {
605
+ return JSON.stringify(queryKey, (_, val) => {
606
+ if (val instanceof Map) {
607
+ // Schwartzian: compute each entry's sort key (recursive hash of the
608
+ // Map key) once, then sort by the cheap string compare.
609
+ const entries = [...val.entries()]
610
+ .map((e) => [hash(e[0]), e])
611
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
612
+ .map(([, e]) => e);
613
+ return { __map__: entries };
614
+ }
615
+ if (val instanceof Set) {
616
+ const values = [...val]
617
+ .map((v) => [hash(v), v])
618
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
619
+ .map(([, v]) => v);
620
+ return { __set__: values };
621
+ }
622
+ if (isHashableObject(val))
623
+ return sortKeys(val);
624
+ return val;
625
+ });
626
+ }
627
+ /**
628
+ * Generates a stable, unique string hash from one or more arguments.
629
+ * Useful for creating cache keys or identifiers where object key order shouldn't matter.
630
+ *
631
+ * How it works:
632
+ * - Object-like values (plain objects, class instances, `Object.create(null)`) have
633
+ * their own enumerable keys sorted alphabetically before hashing. This ensures
634
+ * `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same hash.
635
+ * - `Map` and `Set` are serialized via stable, sorted markers (`__map__` / `__set__`).
636
+ * - Arrays preserve positional order; `Date` uses its ISO string via `toJSON`.
637
+ *
638
+ * @param {...unknown} args Values to include in the hash.
639
+ * @returns A stable string hash representing the input arguments.
640
+ * @example
641
+ * hash('posts', 10);
642
+ * // => '["posts",10]'
643
+ *
644
+ * hash({ a: 1, b: 2 }) === hash({ b: 2, a: 1 }); // true
645
+ *
646
+ * hash(new Map([['a', 1]])) === hash(new Map([['a', 1]])); // true
647
+ *
648
+ * // Be mindful of values JSON.stringify cannot handle (functions, undefined, Symbols)
649
+ * // hash('a', undefined, function() {}) => '["a",null,null]'
650
+ */
651
+ function hash(...args) {
652
+ return hashKey(args);
653
+ }
654
+
655
+ function normalizeParams(params) {
656
+ const p = params instanceof HttpParams
657
+ ? params
658
+ : new HttpParams({ fromObject: params });
659
+ return p
660
+ .keys()
661
+ .toSorted()
662
+ .map((key) => {
663
+ const encodedKey = encodeURIComponent(key);
664
+ return (p.getAll(key) ?? [])
665
+ .map((v) => `${encodedKey}=${encodeURIComponent(v)}`)
666
+ .join('&');
667
+ })
668
+ .join('&');
669
+ }
670
+ function hashBody(body) {
671
+ // File extends Blob — must check File first
672
+ if (typeof File !== 'undefined' && body instanceof File) {
673
+ return `File:${body.name}:${body.type}:${body.size}:${body.lastModified}`;
674
+ }
675
+ if (typeof Blob !== 'undefined' && body instanceof Blob) {
676
+ return `Blob:${body.type}:${body.size}`;
677
+ }
678
+ if (typeof FormData !== 'undefined' && body instanceof FormData) {
679
+ const entries = [];
680
+ body.forEach((value, key) => {
681
+ entries.push([key, hashBody(value)]);
682
+ });
683
+ entries.sort(([ak, av], [bk, bv]) => ak.localeCompare(bk) || av.localeCompare(bv));
684
+ return `FormData:${entries.map(([k, v]) => `${k}=${v}`).join('&')}`;
685
+ }
686
+ if (typeof URLSearchParams !== 'undefined' &&
687
+ body instanceof URLSearchParams) {
688
+ const sp = new URLSearchParams(body);
689
+ sp.sort();
690
+ return `URLSearchParams:${sp.toString()}`;
691
+ }
692
+ if (body instanceof ArrayBuffer) {
693
+ return `ArrayBuffer:${body.byteLength}`;
694
+ }
695
+ if (ArrayBuffer.isView(body)) {
696
+ return `${body.constructor.name}:${body.byteLength}`;
697
+ }
698
+ return hash(body);
699
+ }
700
+ /**
701
+ * Builds a stable cache/dedupe key from an HTTP request shape (accepts both
702
+ * `HttpRequest` and `HttpResourceRequest`).
703
+ *
704
+ * Key composition: `${method}:${url}:${responseType}[:${params}][:${body}]`
705
+ * - `method` defaults to `'GET'`, `responseType` to `'json'` (Angular defaults).
706
+ * - Query params are sorted alphabetically and URL-encoded for stability.
707
+ * - Body hashing handles `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`
708
+ * and typed arrays explicitly; everything else flows through key-sorted
709
+ * `JSON.stringify` via `hash()`.
710
+ */
711
+ function hashRequest(req) {
712
+ const method = req.method ?? 'GET';
713
+ const responseType = req.responseType ?? 'json';
714
+ const base = `${method}:${req.url}:${responseType}`;
715
+ const params = req.params ? `:${normalizeParams(req.params)}` : '';
716
+ const body = req.body != null ? `:${hashBody(req.body)}` : '';
717
+ return base + params + body;
718
+ }
719
+
522
720
  const CACHE_CONTEXT = new HttpContextToken(() => ({
523
721
  cache: false,
524
722
  }));
@@ -662,7 +860,7 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
662
860
  const opt = getCacheContext(req.context);
663
861
  if (!opt.cache)
664
862
  return next(req);
665
- const key = opt.key ?? req.urlWithParams;
863
+ const key = opt.key ?? hashRequest(req);
666
864
  const entry = cache.getUntracked(key); // null if expired or not found
667
865
  // If the entry is not stale, return it
668
866
  if (entry && !entry.isStale)
@@ -682,7 +880,9 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
682
880
  });
683
881
  }
684
882
  return next(req).pipe(tap((event) => {
685
- if (event instanceof HttpResponse && event.ok) {
883
+ if (!(event instanceof HttpResponse))
884
+ return;
885
+ if (event.ok) {
686
886
  const cacheControl = parseCacheControlHeader(event);
687
887
  if (cacheControl.noStore && !opt.ignoreCacheControl)
688
888
  return;
@@ -701,6 +901,17 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
701
901
  })
702
902
  : event;
703
903
  cache.store(key, parsedResponse, staleTime, ttl, opt.persist);
904
+ return;
905
+ }
906
+ // 304 → server confirmed our cached entry is still valid. Re-stamp the
907
+ // existing entry so subsequent reads within the new freshness window
908
+ // don't trigger another revalidation round-trip.
909
+ if (event.status === 304 && entry) {
910
+ const cacheControl = parseCacheControlHeader(event);
911
+ const { staleTime, ttl } = opt.ignoreCacheControl
912
+ ? opt
913
+ : resolveTimings(cacheControl, opt.staleTime, opt.ttl);
914
+ cache.store(key, entry.value, staleTime, ttl, opt.persist);
704
915
  }
705
916
  }), map((event) => {
706
917
  // handle 304 responses due to eTag/last-modified
@@ -728,17 +939,18 @@ function catchValueError(resource, fallback) {
728
939
 
729
940
  /** @internal */
730
941
  const DEFAULT_OPTIONS = {
731
- treshold: 5,
942
+ threshold: 5,
732
943
  timeout: 30000,
733
944
  shouldFail: () => true,
734
945
  shouldFailForever: () => false,
735
946
  };
736
947
  /** @internal */
737
- function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldFail = () => true, shouldFailForever = () => false) {
948
+ function internalCeateCircuitBreaker(threshold = 5, resetTimeout = 30000, shouldFail = () => true, shouldFailForever = () => false) {
738
949
  const halfOpen = signal(false, ...(ngDevMode ? [{ debugName: "halfOpen" }] : []));
739
950
  const failureCount = signal(0, ...(ngDevMode ? [{ debugName: "failureCount" }] : []));
951
+ const failedForever = signal(false, ...(ngDevMode ? [{ debugName: "failedForever" }] : []));
740
952
  const status = computed(() => {
741
- if (failureCount() >= treshold)
953
+ if (failedForever() || failureCount() >= threshold)
742
954
  return 'OPEN';
743
955
  return halfOpen() ? 'HALF_OPEN' : 'CLOSED';
744
956
  }, ...(ngDevMode ? [{ debugName: "status" }] : []));
@@ -752,17 +964,17 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldF
752
964
  if (!untracked(isOpen))
753
965
  return;
754
966
  halfOpen.set(true);
755
- failureCount.set(treshold - 1);
967
+ failureCount.set(threshold - 1);
756
968
  };
757
- let failForeverResetId = null;
969
+ // Auto-probe effect: schedules a half-open retry after `resetTimeout` whenever
970
+ // the breaker is open, *unless* we've been failed forever (in which case only
971
+ // hardReset() can recover).
758
972
  const effectRef = effect((cleanup) => {
759
- if (!isOpen())
973
+ if (!isOpen() || failedForever())
760
974
  return;
761
975
  const timeout = setTimeout(tryOnce, resetTimeout);
762
- failForeverResetId = timeout;
763
976
  return cleanup(() => {
764
977
  clearTimeout(timeout);
765
- failForeverResetId = null;
766
978
  });
767
979
  }, ...(ngDevMode ? [{ debugName: "effectRef" }] : []));
768
980
  const failInternal = () => {
@@ -770,12 +982,8 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldF
770
982
  halfOpen.set(false);
771
983
  };
772
984
  const failForever = () => {
773
- if (failForeverResetId)
774
- clearTimeout(failForeverResetId);
775
- effectRef.destroy();
776
- failureCount.set(Infinity);
985
+ failedForever.set(true);
777
986
  halfOpen.set(false);
778
- return;
779
987
  };
780
988
  const fail = (err) => {
781
989
  if (shouldFailForever(err))
@@ -784,6 +992,11 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldF
784
992
  return failInternal();
785
993
  // If the error does not trigger a failure, we do nothing.
786
994
  };
995
+ const hardReset = () => {
996
+ failedForever.set(false);
997
+ failureCount.set(0);
998
+ halfOpen.set(false);
999
+ };
787
1000
  return {
788
1001
  status,
789
1002
  isClosed,
@@ -791,6 +1004,7 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldF
791
1004
  fail,
792
1005
  success,
793
1006
  halfOpen: tryOnce,
1007
+ hardReset,
794
1008
  destroy: () => effectRef.destroy(),
795
1009
  };
796
1010
  }
@@ -809,18 +1023,43 @@ function createNeverBrokenCircuitBreaker() {
809
1023
  halfOpen: () => {
810
1024
  // noop
811
1025
  },
1026
+ hardReset: () => {
1027
+ // noop
1028
+ },
812
1029
  destroy: () => {
813
1030
  // noop
814
1031
  },
815
1032
  };
816
1033
  }
817
1034
  const CB_DEFAULT_OPTIONS = new InjectionToken('MMSTACK_CIRCUIT_BREAKER_DEFAULT_OPTIONS');
1035
+ /**
1036
+ * Provides application-wide default options for {@link createCircuitBreaker}.
1037
+ * Any `createCircuitBreaker()` call without explicit options (or with only
1038
+ * partial options) merges these defaults in, so you can centralize threshold /
1039
+ * timeout / failure-classifier behavior in one place.
1040
+ *
1041
+ * Per-call options always win over the provided defaults.
1042
+ *
1043
+ * @example
1044
+ * ```ts
1045
+ * bootstrapApplication(AppComponent, {
1046
+ * providers: [
1047
+ * provideCircuitBreakerDefaultOptions({
1048
+ * threshold: 10,
1049
+ * timeout: 60_000,
1050
+ * shouldFailForever: (err) =>
1051
+ * err instanceof HttpErrorResponse && [401, 403].includes(err.status),
1052
+ * }),
1053
+ * ],
1054
+ * });
1055
+ * ```
1056
+ */
818
1057
  function provideCircuitBreakerDefaultOptions(options) {
819
1058
  return {
820
1059
  provide: CB_DEFAULT_OPTIONS,
821
1060
  useValue: {
822
1061
  ...DEFAULT_OPTIONS,
823
- ...options,
1062
+ ...normalizeThreshold(options),
824
1063
  },
825
1064
  };
826
1065
  }
@@ -829,12 +1068,23 @@ function injectCircuitBreakerOptions(injector = inject(Injector)) {
829
1068
  optional: true,
830
1069
  });
831
1070
  }
1071
+ /** @internal — strips the deprecated `treshold` field and folds it into `threshold` */
1072
+ function normalizeThreshold(opt) {
1073
+ if (!opt || typeof opt !== 'object' || 'isClosed' in opt)
1074
+ return {};
1075
+ const { treshold, threshold, ...rest } = opt;
1076
+ return {
1077
+ ...rest,
1078
+ threshold: threshold ?? treshold,
1079
+ };
1080
+ }
832
1081
  /**
833
1082
  * Creates a circuit breaker instance.
834
1083
  *
835
1084
  * @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).
1085
+ * - `undefined`: Uses defaults (threshold: 5, timeout: 30000ms) or provided defaults via {@link provideCircuitBreakerDefaultOptions}.
1086
+ * - `false`: Creates a "no-op" circuit breaker that is always closed (never trips).
1087
+ * - `true`: Creates a circuit breaker with default settings.
838
1088
  * - `CircuitBreaker`: Reuses an existing `CircuitBreaker` instance.
839
1089
  * - `{ threshold?: number; timeout?: number; }`: Creates a circuit breaker with the specified threshold and timeout.
840
1090
  *
@@ -857,11 +1107,11 @@ function createCircuitBreaker(opt, injector) {
857
1107
  return createNeverBrokenCircuitBreaker();
858
1108
  if (typeof opt === 'object' && 'isClosed' in opt)
859
1109
  return opt;
860
- const { treshold, timeout, shouldFail, shouldFailForever } = {
1110
+ const { threshold, timeout, shouldFail, shouldFailForever } = {
861
1111
  ...injectCircuitBreakerOptions(injector),
862
- ...opt,
1112
+ ...normalizeThreshold(opt),
863
1113
  };
864
- return internalCeateCircuitBreaker(treshold, timeout, shouldFail, shouldFailForever);
1114
+ return internalCeateCircuitBreaker(threshold, timeout, shouldFail, shouldFailForever);
865
1115
  }
866
1116
 
867
1117
  // Heavily inspired by: https://dev.to/kasual1/request-deduplication-in-angular-3pd8
@@ -893,6 +1143,9 @@ function noDedupe(ctx = new HttpContext()) {
893
1143
  *
894
1144
  * @param allowed - An array of HTTP methods for which deduplication should be enabled.
895
1145
  * Defaults to `['GET', 'DELETE', 'HEAD', 'OPTIONS']`.
1146
+ * @param keyFn - Optional function to compute the dedupe key from a request.
1147
+ * Defaults to `hashRequest`, which includes method, URL,
1148
+ * response type, params, and body.
896
1149
  *
897
1150
  * @returns An `HttpInterceptorFn` that implements the request deduplication logic.
898
1151
  *
@@ -916,97 +1169,22 @@ function noDedupe(ctx = new HttpContext()) {
916
1169
  * ],
917
1170
  * };
918
1171
  */
919
- function createDedupeRequestsInterceptor(allowed = ['GET', 'DELETE', 'HEAD', 'OPTIONS']) {
1172
+ function createDedupeRequestsInterceptor(allowed = ['GET', 'DELETE', 'HEAD', 'OPTIONS'], keyFn = hashRequest) {
920
1173
  const inFlight = new Map();
921
1174
  const DEDUPE_METHODS = new Set(allowed);
922
1175
  return (req, next) => {
923
1176
  if (!DEDUPE_METHODS.has(req.method) || req.context.get(NO_DEDUPE))
924
1177
  return next(req);
925
- const found = inFlight.get(req.urlWithParams);
1178
+ const key = keyFn(req);
1179
+ const found = inFlight.get(key);
926
1180
  if (found)
927
1181
  return found;
928
- const request = next(req).pipe(finalize(() => inFlight.delete(req.urlWithParams)), shareReplay());
929
- inFlight.set(req.urlWithParams, request);
1182
+ const request = next(req).pipe(finalize(() => inFlight.delete(key)), shareReplay({ bufferSize: 1, refCount: true }));
1183
+ inFlight.set(key, request);
930
1184
  return request;
931
1185
  };
932
1186
  }
933
1187
 
934
- /**
935
- * Checks if `value` is a plain JavaScript object (e.g., `{}` or `new Object()`).
936
- * Distinguishes from arrays, null, and class instances. Acts as a type predicate,
937
- * narrowing `value` to `UnknownObject` if `true`.
938
- *
939
- * @param value The value to check.
940
- * @returns {value is UnknownObject} `true` if `value` is a plain object, otherwise `false`.
941
- * @example
942
- * isPlainObject({}) // => true
943
- * isPlainObject([]) // => false
944
- * isPlainObject(null) // => false
945
- * isPlainObject(new Date()) // => false
946
- */
947
- function isPlainObject(value) {
948
- if (value === null || typeof value !== 'object')
949
- return false;
950
- const proto = Object.getPrototypeOf(value);
951
- if (proto === null)
952
- return false; // remove Object.create(null);
953
- return proto === Object.prototype;
954
- }
955
- /**
956
- * Internal helper to generate a stable JSON string from an array.
957
- * Sorts keys of plain objects within the array alphabetically before serialization
958
- * to ensure hash stability regardless of key order.
959
- *
960
- * @param queryKey The array of values to serialize.
961
- * @returns A stable JSON string representation.
962
- * @internal
963
- */
964
- function hashKey(queryKey) {
965
- return JSON.stringify(queryKey, (_, val) => isPlainObject(val)
966
- ? Object.keys(val)
967
- .toSorted()
968
- .reduce((result, key) => {
969
- result[key] = val[key];
970
- return result;
971
- }, {})
972
- : val);
973
- }
974
- /**
975
- * Generates a stable, unique string hash from one or more arguments.
976
- * Useful for creating cache keys or identifiers where object key order shouldn't matter.
977
- *
978
- * How it works:
979
- * - Plain objects within the arguments have their keys sorted alphabetically before hashing.
980
- * This ensures that `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same hash.
981
- * - Uses `JSON.stringify` internally with custom sorting for plain objects via `hashKey`.
982
- * - Non-plain objects (arrays, Dates, etc.) and primitives are serialized naturally.
983
- *
984
- * @param {...unknown} args Values to include in the hash.
985
- * @returns A stable string hash representing the input arguments.
986
- * @example
987
- * const userQuery = (id: number) => ['user', { id, timestamp: Date.now() }];
988
- *
989
- * const obj1 = { a: 1, b: 2 };
990
- * const obj2 = { b: 2, a: 1 }; // Same keys/values, different order
991
- *
992
- * hash('posts', 10);
993
- * // => '["posts",10]'
994
- *
995
- * hash('config', obj1);
996
- * // => '["config",{"a":1,"b":2}]'
997
- *
998
- * hash('config', obj2);
999
- * // => '["config",{"a":1,"b":2}]' (Same as above due to key sorting)
1000
- *
1001
- * hash(['todos', { status: 'done', owner: obj1 }]);
1002
- * // => '[["todos",{"owner":{"a":1,"b":2},"status":"done"}]]'
1003
- *
1004
- * // Be mindful of values JSON.stringify cannot handle (functions, undefined, Symbols)
1005
- * // hash('a', undefined, function() {}) => '["a",null,null]'
1006
- */
1007
- function hash(...args) {
1008
- return hashKey(args);
1009
- }
1010
1188
  function equalTransferCache(a, b) {
1011
1189
  if (!a && !b)
1012
1190
  return true;
@@ -1240,13 +1418,16 @@ function refresh(resource, destroyRef, refresh) {
1240
1418
  }
1241
1419
 
1242
1420
  // 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) {
1421
+ function retryOnError(res, opt, onError) {
1244
1422
  const max = opt ? (typeof opt === 'number' ? opt : (opt.max ?? 0)) : 0;
1245
1423
  const backoff = typeof opt === 'object' ? (opt.backoff ?? 1000) : 1000;
1246
1424
  let retries = 0;
1247
1425
  let timeout;
1248
- const onError = () => {
1249
- if (retries >= max)
1426
+ const handleError = () => {
1427
+ const err = untracked(res.error);
1428
+ const isFinal = retries >= max;
1429
+ onError?.(err, retries, isFinal);
1430
+ if (isFinal)
1250
1431
  return;
1251
1432
  retries++;
1252
1433
  if (timeout)
@@ -1261,7 +1442,7 @@ function retryOnError(res, opt) {
1261
1442
  const ref = effect(() => {
1262
1443
  switch (res.status()) {
1263
1444
  case 'error':
1264
- return onError();
1445
+ return handleError();
1265
1446
  case 'resolved':
1266
1447
  return onSuccess();
1267
1448
  }
@@ -1308,29 +1489,6 @@ function toResourceObject(res) {
1308
1489
  };
1309
1490
  }
1310
1491
 
1311
- function normalizeParams(params) {
1312
- if (params instanceof HttpParams)
1313
- return params.toString();
1314
- const paramMap = new Map();
1315
- for (const [key, value] of Object.entries(params)) {
1316
- if (Array.isArray(value)) {
1317
- paramMap.set(key, value.map(encodeURIComponent).join(','));
1318
- }
1319
- else {
1320
- paramMap.set(key, encodeURIComponent(value.toString()));
1321
- }
1322
- }
1323
- return Array.from(paramMap.entries())
1324
- .sort(([a], [b]) => a.localeCompare(b))
1325
- .map(([key, value]) => `${key}=${value}`)
1326
- .join('&');
1327
- }
1328
- function urlWithParams(req) {
1329
- if (!req.params)
1330
- return req.url;
1331
- return `${req.url}?${normalizeParams(req.params)}`;
1332
- }
1333
-
1334
1492
  function queryResource(request, options) {
1335
1493
  const cache = injectQueryCache(options?.injector);
1336
1494
  const destroyRef = options?.injector
@@ -1343,10 +1501,20 @@ function queryResource(request, options) {
1343
1501
  const eq = options?.triggerOnSameRequest
1344
1502
  ? undefined
1345
1503
  : createEqualRequest(options?.equal);
1504
+ const rawRequest = computed(() => request() ?? undefined, ...(ngDevMode ? [{ debugName: "rawRequest" }] : []));
1505
+ const disabledReason = computed(() => {
1506
+ if (!networkAvailable())
1507
+ return 'offline';
1508
+ if (cb.isOpen())
1509
+ return 'circuit-open';
1510
+ if (!rawRequest())
1511
+ return 'no-request';
1512
+ return null;
1513
+ }, ...(ngDevMode ? [{ debugName: "disabledReason" }] : []));
1346
1514
  const stableRequest = computed(() => {
1347
- if (!networkAvailable() || cb.isOpen())
1515
+ if (disabledReason() !== null)
1348
1516
  return undefined;
1349
- const req = request();
1517
+ const req = rawRequest();
1350
1518
  if (!req)
1351
1519
  return undefined;
1352
1520
  if (typeof req === 'string')
@@ -1364,8 +1532,8 @@ function queryResource(request, options) {
1364
1532
  },
1365
1533
  }]));
1366
1534
  const hashFn = typeof options?.cache === 'object'
1367
- ? (options.cache.hash ?? urlWithParams)
1368
- : urlWithParams;
1535
+ ? (options.cache.hash ?? hashRequest)
1536
+ : hashRequest;
1369
1537
  const staleTime = typeof options?.cache === 'object' ? options.cache.staleTime : 0;
1370
1538
  const ttl = typeof options?.cache === 'object' ? options.cache.ttl : undefined;
1371
1539
  const cacheKey = computed(() => {
@@ -1444,7 +1612,7 @@ function queryResource(request, options) {
1444
1612
  },
1445
1613
  }]));
1446
1614
  resource = refresh(resource, destroyRef, options?.refresh);
1447
- resource = retryOnError(resource, options?.retry);
1615
+ resource = retryOnError(resource, options?.retry, options?.onError);
1448
1616
  resource = persistResourceValues(resource, options?.keepPrevious, options?.equal);
1449
1617
  const value = options?.cache
1450
1618
  ? toWritable(computed(() => {
@@ -1452,20 +1620,6 @@ function queryResource(request, options) {
1452
1620
  return cacheEntry()?.value ?? resource.value();
1453
1621
  }), resource.value.set, resource.value.update)
1454
1622
  : 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
1623
  // iterate circuit breaker state, is effect as a computed would cause a circular dependency (resource -> cb -> resource)
1470
1624
  const cbEffectRef = effect(() => {
1471
1625
  const status = resource.status();
@@ -1497,7 +1651,8 @@ function queryResource(request, options) {
1497
1651
  update,
1498
1652
  statusCode: linkedSignal(resource.statusCode),
1499
1653
  headers: linkedSignal(resource.headers),
1500
- disabled: computed(() => cb.isOpen() || stableRequest() === undefined),
1654
+ disabled: computed(() => disabledReason() !== null),
1655
+ disabledReason,
1501
1656
  reload: () => {
1502
1657
  cb.halfOpen(); // open the circuit for manual reload
1503
1658
  return resource.reload();