@mmstack/resource 21.4.6 → 21.5.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,10 +1,322 @@
1
- import { HttpHeaders, HttpResponse, HttpParams, HttpContextToken, HttpContext, httpResource, HttpClient } from '@angular/common/http';
1
+ import { HttpHeaders, HttpParams, HttpResponse, HttpContextToken, HttpContext, httpResource, HttpClient } from '@angular/common/http';
2
2
  import * as i0 from '@angular/core';
3
3
  import { isDevMode, signal, computed, untracked, InjectionToken, inject, PLATFORM_ID, DestroyRef, effect, Injector, Injectable, runInInjectionContext, linkedSignal } from '@angular/core';
4
4
  import { mutable, toWritable, keepPrevious, sensor, injectTransitionScope, injectPaused, nestedEffect } from '@mmstack/primitives';
5
5
  import { finalize, shareReplay, of, tap, map, interval, firstValueFrom, catchError, combineLatestWith, filter } from 'rxjs';
6
6
  import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
7
7
 
8
+ /**
9
+ * Returns `true` for any object-like value whose own enumerable keys should
10
+ * be sorted for stable hashing. Excludes arrays (positional), `Date`
11
+ * (handled by `toJSON`), `Map`/`Set` (handled explicitly), and binary types
12
+ * (`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`/typed arrays — these
13
+ * should be branched on before reaching `hash()`, typically by `hashRequest`).
14
+ *
15
+ * Plain objects, class instances, and `Object.create(null)` all qualify.
16
+ */
17
+ function isHashableObject(value) {
18
+ if (value === null || typeof value !== 'object')
19
+ return false;
20
+ if (Array.isArray(value))
21
+ return false;
22
+ if (value instanceof Date)
23
+ return false;
24
+ if (value instanceof Map)
25
+ return false;
26
+ if (value instanceof Set)
27
+ return false;
28
+ if (typeof Blob !== 'undefined' && value instanceof Blob)
29
+ return false;
30
+ if (typeof FormData !== 'undefined' && value instanceof FormData)
31
+ return false;
32
+ if (typeof URLSearchParams !== 'undefined' &&
33
+ value instanceof URLSearchParams)
34
+ return false;
35
+ if (value instanceof ArrayBuffer)
36
+ return false;
37
+ if (ArrayBuffer.isView(value))
38
+ return false;
39
+ return true;
40
+ }
41
+ function sortKeys(val) {
42
+ return Object.keys(val)
43
+ .toSorted()
44
+ .reduce((result, key) => {
45
+ result[key] = val[key];
46
+ return result;
47
+ }, {});
48
+ }
49
+ /**
50
+ * Internal helper to generate a stable JSON string from an array.
51
+ * - Object-like values (plain, class instances, null-proto) get their own
52
+ * enumerable keys sorted alphabetically.
53
+ * - `Map` → marker object with sorted entries (sorted by `JSON.stringify(key)`).
54
+ * - `Set` → marker object with sorted values (sorted by `JSON.stringify(value)`).
55
+ * - Arrays preserve order. `Date` serializes via `toJSON`.
56
+ *
57
+ * @internal
58
+ */
59
+ function hashKey(queryKey) {
60
+ return JSON.stringify(queryKey, (_, val) => {
61
+ if (val instanceof Map) {
62
+ // Schwartzian: compute each entry's sort key (recursive hash of the
63
+ // Map key) once, then sort by the cheap string compare.
64
+ const entries = [...val.entries()]
65
+ .map((e) => [hash(e[0]), e])
66
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
67
+ .map(([, e]) => e);
68
+ return { __map__: entries };
69
+ }
70
+ if (val instanceof Set) {
71
+ const values = [...val]
72
+ .map((v) => [hash(v), v])
73
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
74
+ .map(([, v]) => v);
75
+ return { __set__: values };
76
+ }
77
+ if (isHashableObject(val))
78
+ return sortKeys(val);
79
+ return val;
80
+ });
81
+ }
82
+ /**
83
+ * Generates a stable, unique string hash from one or more arguments.
84
+ * Useful for creating cache keys or identifiers where object key order shouldn't matter.
85
+ *
86
+ * How it works:
87
+ * - Object-like values (plain objects, class instances, `Object.create(null)`) have
88
+ * their own enumerable keys sorted alphabetically before hashing. This ensures
89
+ * `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same hash.
90
+ * - `Map` and `Set` are serialized via stable, sorted markers (`__map__` / `__set__`).
91
+ * - Arrays preserve positional order; `Date` uses its ISO string via `toJSON`.
92
+ *
93
+ * @param {...unknown} args Values to include in the hash.
94
+ * @returns A stable string hash representing the input arguments.
95
+ * @example
96
+ * hash('posts', 10);
97
+ * // => '["posts",10]'
98
+ *
99
+ * hash({ a: 1, b: 2 }) === hash({ b: 2, a: 1 }); // true
100
+ *
101
+ * hash(new Map([['a', 1]])) === hash(new Map([['a', 1]])); // true
102
+ *
103
+ * // Be mindful of values JSON.stringify cannot handle (functions, undefined, Symbols)
104
+ * // hash('a', undefined, function() {}) => '["a",null,null]'
105
+ */
106
+ function hash(...args) {
107
+ return hashKey(args);
108
+ }
109
+
110
+ /**
111
+ * Top-level field separator for auto-generated cache keys. ASCII Unit Separator
112
+ * (`\x1f`) is deliberately content-rare: it never occurs in HTTP method tokens,
113
+ * URLs, or `encodeURIComponent`/digest output (params, vary headers, body hash),
114
+ * so the structural layout stays unambiguous even when a custom `cache.hash`
115
+ * *prepends* a namespace with ordinary chars (e.g. `tenant:${hashRequest(req)}`).
116
+ * Survives `JSON.stringify` (IndexedDB persistence) and `structuredClone`
117
+ * (cross-tab broadcast) intact.
118
+ */
119
+ const KEY_DELIMITER = '\x1f';
120
+ /**
121
+ * Recovers the URL portion of an auto-generated cache key — the segment between
122
+ * the 1st and 2nd {@link KEY_DELIMITER} (the key shape is
123
+ * `method␟url␟responseType[␟…]`). Returns `null` when the key has no delimiter
124
+ * (e.g. produced by a custom `hash` that doesn't follow this shape).
125
+ *
126
+ * A namespace prepended with non-delimiter chars collapses into segment 0
127
+ * (`tenant:GET`), so the URL remains segment 1 — method-agnostic and
128
+ * namespacing-tolerant by construction.
129
+ */
130
+ function extractUrlFromKey(key) {
131
+ const start = key.indexOf(KEY_DELIMITER);
132
+ if (start === -1)
133
+ return null;
134
+ const end = key.indexOf(KEY_DELIMITER, start + 1);
135
+ return end === -1
136
+ ? key.slice(start + 1)
137
+ : key.slice(start + 1, end);
138
+ }
139
+ /**
140
+ * @internal
141
+ * One-way ~64-bit digest from two independent FNV-1a passes. Used for header VALUES in
142
+ * cache keys: keys are persisted (IndexedDB) and broadcast cross-tab, so raw values
143
+ * (auth tokens!) must never appear in them. A single 32-bit digest's 2^-32 collision
144
+ * chance is too thin at a security boundary — two colliding tokens would serve one
145
+ * user's cached data under another user's key; 64 bits puts collisions out of reach.
146
+ * High-entropy secrets are not recoverable from the digest.
147
+ */
148
+ function digestHeaderValue(value) {
149
+ let h1 = 0x811c9dc5; // FNV-1a offset basis
150
+ let h2 = 0xcbf29ce4; // independent second pass
151
+ for (let i = 0; i < value.length; i++) {
152
+ const c = value.charCodeAt(i);
153
+ h1 = Math.imul(h1 ^ c, 0x01000193); // FNV prime
154
+ h2 = Math.imul(h2 ^ c, 0x01000197); // distinct odd multiplier
155
+ }
156
+ return ((h1 >>> 0).toString(16).padStart(8, '0') +
157
+ (h2 >>> 0).toString(16).padStart(8, '0'));
158
+ }
159
+ function readHeader(headers, name) {
160
+ if (!headers)
161
+ return null;
162
+ if (headers instanceof HttpHeaders) {
163
+ const all = headers.getAll(name);
164
+ return all && all.length ? all.join(',') : null;
165
+ }
166
+ // record form — header names are case-insensitive
167
+ const lower = name.toLowerCase();
168
+ for (const key of Object.keys(headers)) {
169
+ if (key.toLowerCase() !== lower)
170
+ continue;
171
+ const value = headers[key];
172
+ if (value == null)
173
+ return null;
174
+ return Array.isArray(value) ? value.join(',') : String(value);
175
+ }
176
+ return null;
177
+ }
178
+ /**
179
+ * Content-negotiation headers whose values are low-entropy and non-identifying —
180
+ * embedded (URI-encoded) raw, keeping keys human-readable and skipping the digest.
181
+ * Anything NOT on this list (Authorization, api keys, tenant/x-* headers — we can't
182
+ * know what they carry) is one-way digested instead.
183
+ */
184
+ const SAFE_RAW_HEADERS = new Set([
185
+ 'accept',
186
+ 'accept-language',
187
+ 'content-language',
188
+ 'content-type',
189
+ ]);
190
+ const UNSAFE_HEADER_MESSAGES = new Map([
191
+ [
192
+ 'cookie',
193
+ "[@mmstack/resource]: varyHeaders includes 'cookie'. Browser-attached cookies never appear on the request object (so this usually partitions nothing), and manually-set cookie values often rotate per-request (shredding the hit rate). The header IS still honored (digested) — but prefer varying on 'Authorization' or a tenant header.",
194
+ ],
195
+ [
196
+ 'set-cookie',
197
+ "[@mmstack/resource]: varyHeaders includes 'set-cookie'. Browser-attached cookies never appear on the request object (so this usually partitions nothing), and manually-set cookie values often rotate per-request (shredding the hit rate). The header IS still honored (digested) — but prefer varying on 'Authorization' or a tenant header.",
198
+ ],
199
+ [
200
+ 'authorization',
201
+ "[@mmstack/resource]: varyHeaders includes 'Authorization'. If your token rotates frequently (e.g., short-lived JWTs), this will cause 100% cache churn on refresh. Consider adding a namespace prefix with the users sub, not using it as a cache-key or using a custom 'cache.hash' function with a stable session/user ID instead.",
202
+ ],
203
+ [
204
+ 'x-request-id',
205
+ "[@mmstack/resource]: varyHeaders includes 'X-Request-ID'. This header is often set to a unique value per-request, which will cause 100% cache churn. Consider removing it from varyHeaders or using a custom 'cache.hash' function that ignores it.",
206
+ ],
207
+ [
208
+ 'x-correlation-id',
209
+ "[@mmstack/resource]: varyHeaders includes 'X-Correlation-ID'. This header is often set to a unique value per-request, which will cause 100% cache churn. Consider removing it from varyHeaders or using a custom 'cache.hash' function that ignores it.",
210
+ ],
211
+ [
212
+ 'if-none-match',
213
+ "[@mmstack/resource]: varyHeaders includes 'If-None-Match'. This header contains ETags that change whenever the server's resource version changes, which will cause cache misses on every update. Consider removing it from varyHeaders or using a custom 'cache.hash' function that ignores it.",
214
+ ],
215
+ [
216
+ 'if-modified-since',
217
+ "[@mmstack/resource]: varyHeaders includes 'If-Modified-Since'. This header contains timestamps that change whenever the server's resource version changes, which will cause cache misses on every update. Consider removing it from varyHeaders or using a custom 'cache.hash' function that ignores it.",
218
+ ],
219
+ ]);
220
+ function normalizeVaryHeaders(headers, names) {
221
+ const isDev = isDevMode();
222
+ return names
223
+ .map((n) => n.toLowerCase())
224
+ .toSorted()
225
+ .map((name) => {
226
+ if (isDev) {
227
+ const warning = UNSAFE_HEADER_MESSAGES.get(name);
228
+ if (warning)
229
+ console.warn(warning);
230
+ }
231
+ const value = readHeader(headers, name);
232
+ if (value === null)
233
+ return `${name}=`;
234
+ // known-safe values raw (readable, cheap); everything else digested, NEVER raw —
235
+ // keys are persisted to IndexedDB and broadcast across tabs
236
+ return SAFE_RAW_HEADERS.has(name)
237
+ ? `${name}=${encodeURIComponent(value)}`
238
+ : `${name}=${digestHeaderValue(value)}`;
239
+ })
240
+ .join('&');
241
+ }
242
+ function normalizeParams(params) {
243
+ const p = params instanceof HttpParams
244
+ ? params
245
+ : new HttpParams({ fromObject: params });
246
+ return p
247
+ .keys()
248
+ .toSorted()
249
+ .map((key) => {
250
+ const encodedKey = encodeURIComponent(key);
251
+ return (p.getAll(key) ?? [])
252
+ .map((v) => `${encodedKey}=${encodeURIComponent(v)}`)
253
+ .join('&');
254
+ })
255
+ .join('&');
256
+ }
257
+ function hashBody(body) {
258
+ // File extends Blob — must check File first
259
+ if (typeof File !== 'undefined' && body instanceof File) {
260
+ return `File:${body.name}:${body.type}:${body.size}:${body.lastModified}`;
261
+ }
262
+ if (typeof Blob !== 'undefined' && body instanceof Blob) {
263
+ return `Blob:${body.type}:${body.size}`;
264
+ }
265
+ if (typeof FormData !== 'undefined' && body instanceof FormData) {
266
+ const entries = [];
267
+ body.forEach((value, key) => {
268
+ entries.push([key, hashBody(value)]);
269
+ });
270
+ entries.sort(([ak, av], [bk, bv]) => ak.localeCompare(bk) || av.localeCompare(bv));
271
+ return `FormData:${entries.map(([k, v]) => `${k}=${v}`).join('&')}`;
272
+ }
273
+ if (typeof URLSearchParams !== 'undefined' &&
274
+ body instanceof URLSearchParams) {
275
+ const sp = new URLSearchParams(body);
276
+ sp.sort();
277
+ return `URLSearchParams:${sp.toString()}`;
278
+ }
279
+ if (body instanceof ArrayBuffer) {
280
+ return `ArrayBuffer:${body.byteLength}`;
281
+ }
282
+ if (ArrayBuffer.isView(body)) {
283
+ return `${body.constructor.name}:${body.byteLength}`;
284
+ }
285
+ return hash(body);
286
+ }
287
+ /**
288
+ * Builds a stable cache/dedupe key from an HTTP request shape (accepts both
289
+ * `HttpRequest` and `HttpResourceRequest`).
290
+ *
291
+ * Key composition: `${method}␟${url}␟${responseType}[␟${params}][␟${body}][␟${vary}]`,
292
+ * where `␟` is {@link KEY_DELIMITER} (ASCII Unit Separator) — a content-rare top-level
293
+ * separator. Sub-fields inside `params`/`vary` keep their own `&`/`=` delimiters.
294
+ * - `method` defaults to `'GET'`, `responseType` to `'json'` (Angular defaults).
295
+ * - Query params are sorted alphabetically and URL-encoded for stability.
296
+ * - Body hashing handles `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`
297
+ * and typed arrays explicitly; everything else flows through key-sorted
298
+ * `JSON.stringify` via `hash()`.
299
+ * - `varyHeaders` (opt-in) mixes the named request headers into the key so responses
300
+ * that differ per header (e.g. `Authorization` → per-user, `Accept-Language`) get
301
+ * separate entries. Known-safe content-negotiation headers (`Accept`,
302
+ * `Accept-Language`, `Content-Language`, `Content-Type`) embed their value raw for
303
+ * readable keys; all other header VALUES are one-way digested, never embedded raw —
304
+ * keys are persisted to IndexedDB and broadcast across tabs.
305
+ */
306
+ function hashRequest(req, varyHeaders) {
307
+ const method = req.method ?? 'GET';
308
+ const responseType = req.responseType ?? 'json';
309
+ const base = `${method}${KEY_DELIMITER}${req.url}${KEY_DELIMITER}${responseType}`;
310
+ const params = req.params
311
+ ? `${KEY_DELIMITER}${normalizeParams(req.params)}`
312
+ : '';
313
+ const body = req.body != null ? `${KEY_DELIMITER}${hashBody(req.body)}` : '';
314
+ const vary = varyHeaders?.length
315
+ ? `${KEY_DELIMITER}vary(${normalizeVaryHeaders(req.headers, varyHeaders)})`
316
+ : '';
317
+ return base + params + body + vary;
318
+ }
319
+
8
320
  function createNoopDB() {
9
321
  return {
10
322
  getAll: async () => [],
@@ -141,6 +453,8 @@ class Cache {
141
453
  hydrated = false;
142
454
  /** Keys invalidated while hydration was still in flight — must not be resurrected by it. */
143
455
  hydrationTombstones = new Set();
456
+ /** Dev-only: ensures the "foreign keys, no matcher" hint in invalidateUrlPrefix fires at most once. */
457
+ warnedForeignKeys = false;
144
458
  hitCount = signal(0, ...(ngDevMode ? [{ debugName: "hitCount" }] : /* istanbul ignore next */ []));
145
459
  missCount = signal(0, ...(ngDevMode ? [{ debugName: "missCount" }] : /* istanbul ignore next */ []));
146
460
  /**
@@ -433,6 +747,55 @@ class Cache {
433
747
  invalidatePrefix(prefix) {
434
748
  return this.invalidateWhere((key) => key.startsWith(prefix));
435
749
  }
750
+ /**
751
+ * Invalidates every cache entry whose *request URL* starts with `urlPrefix`,
752
+ * regardless of HTTP method. This is the engine behind `mutationResource`'s
753
+ * `invalidates` option: `'/api/posts'` clears `/api/posts` with any query
754
+ * params, subpaths like `/api/posts/123`, and all `varyHeaders` variants —
755
+ * across GET/HEAD/OPTIONS/POST or any other cached method. Returns the number
756
+ * of entries removed.
757
+ *
758
+ * Unlike {@link invalidatePrefix} (which matches the raw key from its start),
759
+ * this extracts the URL field from the auto-generated key shape, so it is not
760
+ * fooled by the leading method token nor by a namespace a custom `cache.hash`
761
+ * prepends (e.g. `tenant:…`). Plain prefix matching still catches siblings
762
+ * sharing the prefix (`/api/posts-archive`) — pass `'/api/posts/'` to narrow.
763
+ *
764
+ * Keys produced by a custom `hash` that don't follow the auto shape won't be
765
+ * matched by the default; pass `match` to describe how a URL prefix maps onto
766
+ * your key format. In dev mode, if a default-matcher call removes nothing and
767
+ * every cached key is foreign-shaped, this logs a one-time hint pointing at the
768
+ * `match` escape hatch (a likely sign of a custom `hash` with no matcher wired up).
769
+ *
770
+ * @param urlPrefix - URL prefix to match.
771
+ * @param match - Optional custom matcher: given the prefix, returns a key predicate.
772
+ *
773
+ * @example
774
+ * cache.invalidateUrlPrefix('/api/posts');
775
+ * // custom key scheme:
776
+ * cache.invalidateUrlPrefix('/api/posts', (p) => (k) => k.includes(`|url=${p}`));
777
+ */
778
+ invalidateUrlPrefix(urlPrefix, match) {
779
+ if (match)
780
+ return this.invalidateWhere(match(urlPrefix));
781
+ let sawAutoKey = false;
782
+ const removed = this.invalidateWhere((key) => {
783
+ const url = extractUrlFromKey(key);
784
+ if (url === null)
785
+ return false; // foreign-shaped key
786
+ sawAutoKey = true;
787
+ return url.startsWith(urlPrefix);
788
+ });
789
+ if (isDevMode() &&
790
+ !this.warnedForeignKeys &&
791
+ removed === 0 &&
792
+ !sawAutoKey &&
793
+ untracked(this.internal).size > 0) {
794
+ this.warnedForeignKeys = true;
795
+ console.warn(`[@mmstack/resource] invalidateUrlPrefix('${urlPrefix}') matched nothing, and no cached key follows the default key shape. If you use a custom 'cache.hash', pass an 'invalidateMatcher' (mutationResource) / 'match' (invalidateUrlPrefix) so invalidation can locate your keys.`);
796
+ }
797
+ return removed;
798
+ }
436
799
  /**
437
800
  * Invalidates every cache entry whose key matches the predicate. Use for
438
801
  * arbitrary bulk invalidation that doesn't fit prefix matching (e.g.
@@ -595,372 +958,93 @@ function provideQueryCache(opt) {
595
958
  const value = deserialize(entry.value);
596
959
  if (value === null)
597
960
  return null;
598
- return {
599
- ...entry,
600
- value,
601
- };
602
- })
603
- .filter((e) => e !== null);
604
- });
605
- },
606
- store: (entry) => {
607
- return db.store({ ...entry, value: serialize(entry.value) });
608
- },
609
- remove: db.remove,
610
- };
611
- });
612
- const cache = new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup, syncTabsOpt, db);
613
- // release the sweep interval / channel with the providing injector
614
- inject(DestroyRef, { optional: true })?.onDestroy(() => cache.destroy());
615
- return cache;
616
- },
617
- };
618
- }
619
- class NoopCache extends Cache {
620
- constructor() {
621
- // Infinity checkInterval → no sweep interval is ever armed, so the shared
622
- // instance below never pins a timer
623
- super(undefined, undefined, {
624
- type: 'lru',
625
- maxSize: 200,
626
- checkInterval: Infinity,
627
- });
628
- }
629
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
630
- store(_, __, ___ = super.staleTime, ____ = super.ttl) {
631
- // noop
632
- }
633
- }
634
- // one shared instance — minting a NoopCache per injectQueryCache() miss would leak
635
- // an instance (and previously an interval) on every prod call without a provider
636
- let NOOP_CACHE;
637
- /**
638
- * Injects the `QueryCache` instance that is used within queryResource.
639
- * Allows for direct modification of cached data, but is mostly meant for internal use.
640
- *
641
- * @param injector - (Optional) The injector to use. If not provided, the current
642
- * injection context is used.
643
- * @returns The `QueryCache` instance.
644
- *
645
- * @example
646
- * // In your component or service:
647
- *
648
- * import { injectQueryCache } from './your-cache';
649
- *
650
- * constructor() {
651
- * const cache = injectQueryCache();
652
- *
653
- * const myData = cache.get(() => 'my-data-key');
654
- * if (myData() !== null) {
655
- * // ... use cached data ...
656
- * }
657
- * }
658
- */
659
- function injectQueryCache(injector) {
660
- const cache = injector
661
- ? injector.get(CLIENT_CACHE_TOKEN, null, {
662
- optional: true,
663
- })
664
- : inject(CLIENT_CACHE_TOKEN, {
665
- optional: true,
666
- });
667
- if (!cache) {
668
- if (isDevMode())
669
- throw new Error('Cache not provided, please add provideQueryCache() to providers array');
670
- else
671
- return (NOOP_CACHE ??= new NoopCache());
672
- }
673
- return cache;
674
- }
675
- /**
676
- * Injects the cache statistics, including the current size of the cache and the number of hits and misses.
677
- *
678
- * @param injector - (Optional) The injector to use. If not provided, the current
679
- * injection context is used.
680
- * @returns A signal containing the cache statistics.
681
- */
682
- function injectCacheStats(injector) {
683
- const cache = injectQueryCache(injector);
684
- return cache.stats;
685
- }
686
-
687
- /**
688
- * Returns `true` for any object-like value whose own enumerable keys should
689
- * be sorted for stable hashing. Excludes arrays (positional), `Date`
690
- * (handled by `toJSON`), `Map`/`Set` (handled explicitly), and binary types
691
- * (`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`/typed arrays — these
692
- * should be branched on before reaching `hash()`, typically by `hashRequest`).
693
- *
694
- * Plain objects, class instances, and `Object.create(null)` all qualify.
695
- */
696
- function isHashableObject(value) {
697
- if (value === null || typeof value !== 'object')
698
- return false;
699
- if (Array.isArray(value))
700
- return false;
701
- if (value instanceof Date)
702
- return false;
703
- if (value instanceof Map)
704
- return false;
705
- if (value instanceof Set)
706
- return false;
707
- if (typeof Blob !== 'undefined' && value instanceof Blob)
708
- return false;
709
- if (typeof FormData !== 'undefined' && value instanceof FormData)
710
- return false;
711
- if (typeof URLSearchParams !== 'undefined' &&
712
- value instanceof URLSearchParams)
713
- return false;
714
- if (value instanceof ArrayBuffer)
715
- return false;
716
- if (ArrayBuffer.isView(value))
717
- return false;
718
- return true;
719
- }
720
- function sortKeys(val) {
721
- return Object.keys(val)
722
- .toSorted()
723
- .reduce((result, key) => {
724
- result[key] = val[key];
725
- return result;
726
- }, {});
727
- }
728
- /**
729
- * Internal helper to generate a stable JSON string from an array.
730
- * - Object-like values (plain, class instances, null-proto) get their own
731
- * enumerable keys sorted alphabetically.
732
- * - `Map` → marker object with sorted entries (sorted by `JSON.stringify(key)`).
733
- * - `Set` → marker object with sorted values (sorted by `JSON.stringify(value)`).
734
- * - Arrays preserve order. `Date` serializes via `toJSON`.
735
- *
736
- * @internal
737
- */
738
- function hashKey(queryKey) {
739
- return JSON.stringify(queryKey, (_, val) => {
740
- if (val instanceof Map) {
741
- // Schwartzian: compute each entry's sort key (recursive hash of the
742
- // Map key) once, then sort by the cheap string compare.
743
- const entries = [...val.entries()]
744
- .map((e) => [hash(e[0]), e])
745
- .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
746
- .map(([, e]) => e);
747
- return { __map__: entries };
748
- }
749
- if (val instanceof Set) {
750
- const values = [...val]
751
- .map((v) => [hash(v), v])
752
- .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
753
- .map(([, v]) => v);
754
- return { __set__: values };
755
- }
756
- if (isHashableObject(val))
757
- return sortKeys(val);
758
- return val;
759
- });
961
+ return {
962
+ ...entry,
963
+ value,
964
+ };
965
+ })
966
+ .filter((e) => e !== null);
967
+ });
968
+ },
969
+ store: (entry) => {
970
+ return db.store({ ...entry, value: serialize(entry.value) });
971
+ },
972
+ remove: db.remove,
973
+ };
974
+ });
975
+ const cache = new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup, syncTabsOpt, db);
976
+ // release the sweep interval / channel with the providing injector
977
+ inject(DestroyRef, { optional: true })?.onDestroy(() => cache.destroy());
978
+ return cache;
979
+ },
980
+ };
981
+ }
982
+ class NoopCache extends Cache {
983
+ constructor() {
984
+ // Infinity checkInterval → no sweep interval is ever armed, so the shared
985
+ // instance below never pins a timer
986
+ super(undefined, undefined, {
987
+ type: 'lru',
988
+ maxSize: 200,
989
+ checkInterval: Infinity,
990
+ });
991
+ }
992
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
993
+ store(_, __, ___ = super.staleTime, ____ = super.ttl) {
994
+ // noop
995
+ }
760
996
  }
997
+ // one shared instance — minting a NoopCache per injectQueryCache() miss would leak
998
+ // an instance (and previously an interval) on every prod call without a provider
999
+ let NOOP_CACHE;
761
1000
  /**
762
- * Generates a stable, unique string hash from one or more arguments.
763
- * Useful for creating cache keys or identifiers where object key order shouldn't matter.
1001
+ * Injects the `QueryCache` instance that is used within queryResource.
1002
+ * Allows for direct modification of cached data, but is mostly meant for internal use.
764
1003
  *
765
- * How it works:
766
- * - Object-like values (plain objects, class instances, `Object.create(null)`) have
767
- * their own enumerable keys sorted alphabetically before hashing. This ensures
768
- * `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same hash.
769
- * - `Map` and `Set` are serialized via stable, sorted markers (`__map__` / `__set__`).
770
- * - Arrays preserve positional order; `Date` uses its ISO string via `toJSON`.
1004
+ * @param injector - (Optional) The injector to use. If not provided, the current
1005
+ * injection context is used.
1006
+ * @returns The `QueryCache` instance.
771
1007
  *
772
- * @param {...unknown} args Values to include in the hash.
773
- * @returns A stable string hash representing the input arguments.
774
1008
  * @example
775
- * hash('posts', 10);
776
- * // => '["posts",10]'
1009
+ * // In your component or service:
777
1010
  *
778
- * hash({ a: 1, b: 2 }) === hash({ b: 2, a: 1 }); // true
1011
+ * import { injectQueryCache } from './your-cache';
779
1012
  *
780
- * hash(new Map([['a', 1]])) === hash(new Map([['a', 1]])); // true
1013
+ * constructor() {
1014
+ * const cache = injectQueryCache();
781
1015
  *
782
- * // Be mindful of values JSON.stringify cannot handle (functions, undefined, Symbols)
783
- * // hash('a', undefined, function() {}) => '["a",null,null]'
784
- */
785
- function hash(...args) {
786
- return hashKey(args);
787
- }
788
-
789
- /**
790
- * @internal
791
- * One-way ~64-bit digest from two independent FNV-1a passes. Used for header VALUES in
792
- * cache keys: keys are persisted (IndexedDB) and broadcast cross-tab, so raw values
793
- * (auth tokens!) must never appear in them. A single 32-bit digest's 2^-32 collision
794
- * chance is too thin at a security boundary — two colliding tokens would serve one
795
- * user's cached data under another user's key; 64 bits puts collisions out of reach.
796
- * High-entropy secrets are not recoverable from the digest.
797
- */
798
- function digestHeaderValue(value) {
799
- let h1 = 0x811c9dc5; // FNV-1a offset basis
800
- let h2 = 0xcbf29ce4; // independent second pass
801
- for (let i = 0; i < value.length; i++) {
802
- const c = value.charCodeAt(i);
803
- h1 = Math.imul(h1 ^ c, 0x01000193); // FNV prime
804
- h2 = Math.imul(h2 ^ c, 0x01000197); // distinct odd multiplier
805
- }
806
- return ((h1 >>> 0).toString(16).padStart(8, '0') +
807
- (h2 >>> 0).toString(16).padStart(8, '0'));
808
- }
809
- function readHeader(headers, name) {
810
- if (!headers)
811
- return null;
812
- if (headers instanceof HttpHeaders) {
813
- const all = headers.getAll(name);
814
- return all && all.length ? all.join(',') : null;
815
- }
816
- // record form — header names are case-insensitive
817
- const lower = name.toLowerCase();
818
- for (const key of Object.keys(headers)) {
819
- if (key.toLowerCase() !== lower)
820
- continue;
821
- const value = headers[key];
822
- if (value == null)
823
- return null;
824
- return Array.isArray(value) ? value.join(',') : String(value);
825
- }
826
- return null;
827
- }
828
- /**
829
- * Content-negotiation headers whose values are low-entropy and non-identifying —
830
- * embedded (URI-encoded) raw, keeping keys human-readable and skipping the digest.
831
- * Anything NOT on this list (Authorization, api keys, tenant/x-* headers — we can't
832
- * know what they carry) is one-way digested instead.
1016
+ * const myData = cache.get(() => 'my-data-key');
1017
+ * if (myData() !== null) {
1018
+ * // ... use cached data ...
1019
+ * }
1020
+ * }
833
1021
  */
834
- const SAFE_RAW_HEADERS = new Set([
835
- 'accept',
836
- 'accept-language',
837
- 'content-language',
838
- 'content-type',
839
- ]);
840
- const UNSAFE_HEADER_MESSAGES = new Map([
841
- [
842
- 'cookie',
843
- "[@mmstack/resource]: varyHeaders includes 'cookie'. Browser-attached cookies never appear on the request object (so this usually partitions nothing), and manually-set cookie values often rotate per-request (shredding the hit rate). The header IS still honored (digested) — but prefer varying on 'Authorization' or a tenant header.",
844
- ],
845
- [
846
- 'set-cookie',
847
- "[@mmstack/resource]: varyHeaders includes 'set-cookie'. Browser-attached cookies never appear on the request object (so this usually partitions nothing), and manually-set cookie values often rotate per-request (shredding the hit rate). The header IS still honored (digested) — but prefer varying on 'Authorization' or a tenant header.",
848
- ],
849
- [
850
- 'authorization',
851
- "[@mmstack/resource]: varyHeaders includes 'Authorization'. If your token rotates frequently (e.g., short-lived JWTs), this will cause 100% cache churn on refresh. Consider adding a namespace prefix with the users sub, not using it as a cache-key or using a custom 'cache.hash' function with a stable session/user ID instead.",
852
- ],
853
- [
854
- 'x-request-id',
855
- "[@mmstack/resource]: varyHeaders includes 'X-Request-ID'. This header is often set to a unique value per-request, which will cause 100% cache churn. Consider removing it from varyHeaders or using a custom 'cache.hash' function that ignores it.",
856
- ],
857
- [
858
- 'x-correlation-id',
859
- "[@mmstack/resource]: varyHeaders includes 'X-Correlation-ID'. This header is often set to a unique value per-request, which will cause 100% cache churn. Consider removing it from varyHeaders or using a custom 'cache.hash' function that ignores it.",
860
- ],
861
- [
862
- 'if-none-match',
863
- "[@mmstack/resource]: varyHeaders includes 'If-None-Match'. This header contains ETags that change whenever the server's resource version changes, which will cause cache misses on every update. Consider removing it from varyHeaders or using a custom 'cache.hash' function that ignores it.",
864
- ],
865
- [
866
- 'if-modified-since',
867
- "[@mmstack/resource]: varyHeaders includes 'If-Modified-Since'. This header contains timestamps that change whenever the server's resource version changes, which will cause cache misses on every update. Consider removing it from varyHeaders or using a custom 'cache.hash' function that ignores it.",
868
- ],
869
- ]);
870
- function normalizeVaryHeaders(headers, names) {
871
- const isDev = isDevMode();
872
- return names
873
- .map((n) => n.toLowerCase())
874
- .toSorted()
875
- .map((name) => {
876
- if (isDev) {
877
- const warning = UNSAFE_HEADER_MESSAGES.get(name);
878
- if (warning)
879
- console.warn(warning);
880
- }
881
- const value = readHeader(headers, name);
882
- if (value === null)
883
- return `${name}=`;
884
- // known-safe values raw (readable, cheap); everything else digested, NEVER raw —
885
- // keys are persisted to IndexedDB and broadcast across tabs
886
- return SAFE_RAW_HEADERS.has(name)
887
- ? `${name}=${encodeURIComponent(value)}`
888
- : `${name}=${digestHeaderValue(value)}`;
889
- })
890
- .join('&');
891
- }
892
- function normalizeParams(params) {
893
- const p = params instanceof HttpParams
894
- ? params
895
- : new HttpParams({ fromObject: params });
896
- return p
897
- .keys()
898
- .toSorted()
899
- .map((key) => {
900
- const encodedKey = encodeURIComponent(key);
901
- return (p.getAll(key) ?? [])
902
- .map((v) => `${encodedKey}=${encodeURIComponent(v)}`)
903
- .join('&');
904
- })
905
- .join('&');
906
- }
907
- function hashBody(body) {
908
- // File extends Blob — must check File first
909
- if (typeof File !== 'undefined' && body instanceof File) {
910
- return `File:${body.name}:${body.type}:${body.size}:${body.lastModified}`;
911
- }
912
- if (typeof Blob !== 'undefined' && body instanceof Blob) {
913
- return `Blob:${body.type}:${body.size}`;
914
- }
915
- if (typeof FormData !== 'undefined' && body instanceof FormData) {
916
- const entries = [];
917
- body.forEach((value, key) => {
918
- entries.push([key, hashBody(value)]);
1022
+ function injectQueryCache(injector) {
1023
+ const cache = injector
1024
+ ? injector.get(CLIENT_CACHE_TOKEN, null, {
1025
+ optional: true,
1026
+ })
1027
+ : inject(CLIENT_CACHE_TOKEN, {
1028
+ optional: true,
919
1029
  });
920
- entries.sort(([ak, av], [bk, bv]) => ak.localeCompare(bk) || av.localeCompare(bv));
921
- return `FormData:${entries.map(([k, v]) => `${k}=${v}`).join('&')}`;
922
- }
923
- if (typeof URLSearchParams !== 'undefined' &&
924
- body instanceof URLSearchParams) {
925
- const sp = new URLSearchParams(body);
926
- sp.sort();
927
- return `URLSearchParams:${sp.toString()}`;
928
- }
929
- if (body instanceof ArrayBuffer) {
930
- return `ArrayBuffer:${body.byteLength}`;
931
- }
932
- if (ArrayBuffer.isView(body)) {
933
- return `${body.constructor.name}:${body.byteLength}`;
1030
+ if (!cache) {
1031
+ if (isDevMode())
1032
+ throw new Error('Cache not provided, please add provideQueryCache() to providers array');
1033
+ else
1034
+ return (NOOP_CACHE ??= new NoopCache());
934
1035
  }
935
- return hash(body);
1036
+ return cache;
936
1037
  }
937
1038
  /**
938
- * Builds a stable cache/dedupe key from an HTTP request shape (accepts both
939
- * `HttpRequest` and `HttpResourceRequest`).
1039
+ * Injects the cache statistics, including the current size of the cache and the number of hits and misses.
940
1040
  *
941
- * Key composition: `${method}:${url}:${responseType}[:${params}][:${body}][:${vary}]`
942
- * - `method` defaults to `'GET'`, `responseType` to `'json'` (Angular defaults).
943
- * - Query params are sorted alphabetically and URL-encoded for stability.
944
- * - Body hashing handles `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`
945
- * and typed arrays explicitly; everything else flows through key-sorted
946
- * `JSON.stringify` via `hash()`.
947
- * - `varyHeaders` (opt-in) mixes the named request headers into the key so responses
948
- * that differ per header (e.g. `Authorization` → per-user, `Accept-Language`) get
949
- * separate entries. Known-safe content-negotiation headers (`Accept`,
950
- * `Accept-Language`, `Content-Language`, `Content-Type`) embed their value raw for
951
- * readable keys; all other header VALUES are one-way digested, never embedded raw —
952
- * keys are persisted to IndexedDB and broadcast across tabs.
1041
+ * @param injector - (Optional) The injector to use. If not provided, the current
1042
+ * injection context is used.
1043
+ * @returns A signal containing the cache statistics.
953
1044
  */
954
- function hashRequest(req, varyHeaders) {
955
- const method = req.method ?? 'GET';
956
- const responseType = req.responseType ?? 'json';
957
- const base = `${method}:${req.url}:${responseType}`;
958
- const params = req.params ? `:${normalizeParams(req.params)}` : '';
959
- const body = req.body != null ? `:${hashBody(req.body)}` : '';
960
- const vary = varyHeaders?.length
961
- ? `:vary(${normalizeVaryHeaders(req.headers, varyHeaders)})`
962
- : '';
963
- return base + params + body + vary;
1045
+ function injectCacheStats(injector) {
1046
+ const cache = injectQueryCache(injector);
1047
+ return cache.stats;
964
1048
  }
965
1049
 
966
1050
  /**
@@ -2395,9 +2479,7 @@ function mutationResource(request, options0 = {}) {
2395
2479
  circuitBreaker: mergeCircuitBreakerOptions(globalOpts.circuitBreaker, mutOpts.circuitBreaker, options0?.circuitBreaker),
2396
2480
  retry: mergeRetryOptions(globalOpts.retry, mutOpts.retry, options0?.retry),
2397
2481
  };
2398
- // `register` is pulled out (and forced off on the inner query below) so the mutation ref is
2399
- // the only thing registered into the transition scope, not its internal query resource.
2400
- const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, invalidates, ...rest } = options;
2482
+ const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, invalidates, invalidateMatcher, ...rest } = options;
2401
2483
  const cache = invalidates ? injectQueryCache(options.injector) : undefined;
2402
2484
  const requestEqual = equalRequest ?? createEqualRequest(equal);
2403
2485
  const triggerOnSame = options.triggerOnSameRequest ?? false;
@@ -2555,7 +2637,7 @@ function mutationResource(request, options0 = {}) {
2555
2637
  ? invalidates(result.value, (mutation === NULL_VALUE ? undefined : mutation))
2556
2638
  : invalidates;
2557
2639
  for (const prefix of prefixes)
2558
- cache.invalidatePrefix(`GET:${prefix}`);
2640
+ cache.invalidateUrlPrefix(prefix, invalidateMatcher);
2559
2641
  }
2560
2642
  deferred?.resolve(result.value);
2561
2643
  }