@mmstack/resource 20.8.6 → 20.9.1

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,6 +1,6 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { InjectionToken, inject, runInInjectionContext, DestroyRef, isDevMode, signal, computed, untracked, PLATFORM_ID, effect, Injector, Injectable, linkedSignal } from '@angular/core';
3
- import { HttpHeaders, HttpResponse, HttpParams, HttpContextToken, HttpContext, httpResource, HttpClient } from '@angular/common/http';
3
+ import { HttpHeaders, HttpParams, HttpResponse, HttpContextToken, HttpContext, httpResource, HttpClient } from '@angular/common/http';
4
4
  import { injectTransitionScope, mutable, toWritable, keepPrevious, sensor, 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';
@@ -42,6 +42,318 @@ function applyResourceRegistration(ref, register, injector) {
42
42
  });
43
43
  }
44
44
 
45
+ /**
46
+ * Returns `true` for any object-like value whose own enumerable keys should
47
+ * be sorted for stable hashing. Excludes arrays (positional), `Date`
48
+ * (handled by `toJSON`), `Map`/`Set` (handled explicitly), and binary types
49
+ * (`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`/typed arrays — these
50
+ * should be branched on before reaching `hash()`, typically by `hashRequest`).
51
+ *
52
+ * Plain objects, class instances, and `Object.create(null)` all qualify.
53
+ */
54
+ function isHashableObject(value) {
55
+ if (value === null || typeof value !== 'object')
56
+ return false;
57
+ if (Array.isArray(value))
58
+ return false;
59
+ if (value instanceof Date)
60
+ return false;
61
+ if (value instanceof Map)
62
+ return false;
63
+ if (value instanceof Set)
64
+ return false;
65
+ if (typeof Blob !== 'undefined' && value instanceof Blob)
66
+ return false;
67
+ if (typeof FormData !== 'undefined' && value instanceof FormData)
68
+ return false;
69
+ if (typeof URLSearchParams !== 'undefined' &&
70
+ value instanceof URLSearchParams)
71
+ return false;
72
+ if (value instanceof ArrayBuffer)
73
+ return false;
74
+ if (ArrayBuffer.isView(value))
75
+ return false;
76
+ return true;
77
+ }
78
+ function sortKeys(val) {
79
+ return Object.keys(val)
80
+ .toSorted()
81
+ .reduce((result, key) => {
82
+ result[key] = val[key];
83
+ return result;
84
+ }, {});
85
+ }
86
+ /**
87
+ * Internal helper to generate a stable JSON string from an array.
88
+ * - Object-like values (plain, class instances, null-proto) get their own
89
+ * enumerable keys sorted alphabetically.
90
+ * - `Map` → marker object with sorted entries (sorted by `JSON.stringify(key)`).
91
+ * - `Set` → marker object with sorted values (sorted by `JSON.stringify(value)`).
92
+ * - Arrays preserve order. `Date` serializes via `toJSON`.
93
+ *
94
+ * @internal
95
+ */
96
+ function hashKey(queryKey) {
97
+ return JSON.stringify(queryKey, (_, val) => {
98
+ if (val instanceof Map) {
99
+ // Schwartzian: compute each entry's sort key (recursive hash of the
100
+ // Map key) once, then sort by the cheap string compare.
101
+ const entries = [...val.entries()]
102
+ .map((e) => [hash(e[0]), e])
103
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
104
+ .map(([, e]) => e);
105
+ return { __map__: entries };
106
+ }
107
+ if (val instanceof Set) {
108
+ const values = [...val]
109
+ .map((v) => [hash(v), v])
110
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
111
+ .map(([, v]) => v);
112
+ return { __set__: values };
113
+ }
114
+ if (isHashableObject(val))
115
+ return sortKeys(val);
116
+ return val;
117
+ });
118
+ }
119
+ /**
120
+ * Generates a stable, unique string hash from one or more arguments.
121
+ * Useful for creating cache keys or identifiers where object key order shouldn't matter.
122
+ *
123
+ * How it works:
124
+ * - Object-like values (plain objects, class instances, `Object.create(null)`) have
125
+ * their own enumerable keys sorted alphabetically before hashing. This ensures
126
+ * `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same hash.
127
+ * - `Map` and `Set` are serialized via stable, sorted markers (`__map__` / `__set__`).
128
+ * - Arrays preserve positional order; `Date` uses its ISO string via `toJSON`.
129
+ *
130
+ * @param {...unknown} args Values to include in the hash.
131
+ * @returns A stable string hash representing the input arguments.
132
+ * @example
133
+ * hash('posts', 10);
134
+ * // => '["posts",10]'
135
+ *
136
+ * hash({ a: 1, b: 2 }) === hash({ b: 2, a: 1 }); // true
137
+ *
138
+ * hash(new Map([['a', 1]])) === hash(new Map([['a', 1]])); // true
139
+ *
140
+ * // Be mindful of values JSON.stringify cannot handle (functions, undefined, Symbols)
141
+ * // hash('a', undefined, function() {}) => '["a",null,null]'
142
+ */
143
+ function hash(...args) {
144
+ return hashKey(args);
145
+ }
146
+
147
+ /**
148
+ * Top-level field separator for auto-generated cache keys. ASCII Unit Separator
149
+ * (`\x1f`) is deliberately content-rare: it never occurs in HTTP method tokens,
150
+ * URLs, or `encodeURIComponent`/digest output (params, vary headers, body hash),
151
+ * so the structural layout stays unambiguous even when a custom `cache.hash`
152
+ * *prepends* a namespace with ordinary chars (e.g. `tenant:${hashRequest(req)}`).
153
+ * Survives `JSON.stringify` (IndexedDB persistence) and `structuredClone`
154
+ * (cross-tab broadcast) intact.
155
+ */
156
+ const KEY_DELIMITER = '\x1f';
157
+ /**
158
+ * Recovers the URL portion of an auto-generated cache key — the segment between
159
+ * the 1st and 2nd {@link KEY_DELIMITER} (the key shape is
160
+ * `method␟url␟responseType[␟…]`). Returns `null` when the key has no delimiter
161
+ * (e.g. produced by a custom `hash` that doesn't follow this shape).
162
+ *
163
+ * A namespace prepended with non-delimiter chars collapses into segment 0
164
+ * (`tenant:GET`), so the URL remains segment 1 — method-agnostic and
165
+ * namespacing-tolerant by construction.
166
+ */
167
+ function extractUrlFromKey(key) {
168
+ const start = key.indexOf(KEY_DELIMITER);
169
+ if (start === -1)
170
+ return null;
171
+ const end = key.indexOf(KEY_DELIMITER, start + 1);
172
+ return end === -1
173
+ ? key.slice(start + 1)
174
+ : key.slice(start + 1, end);
175
+ }
176
+ /**
177
+ * @internal
178
+ * One-way ~64-bit digest from two independent FNV-1a passes. Used for header VALUES in
179
+ * cache keys: keys are persisted (IndexedDB) and broadcast cross-tab, so raw values
180
+ * (auth tokens!) must never appear in them. A single 32-bit digest's 2^-32 collision
181
+ * chance is too thin at a security boundary — two colliding tokens would serve one
182
+ * user's cached data under another user's key; 64 bits puts collisions out of reach.
183
+ * High-entropy secrets are not recoverable from the digest.
184
+ */
185
+ function digestHeaderValue(value) {
186
+ let h1 = 0x811c9dc5; // FNV-1a offset basis
187
+ let h2 = 0xcbf29ce4; // independent second pass
188
+ for (let i = 0; i < value.length; i++) {
189
+ const c = value.charCodeAt(i);
190
+ h1 = Math.imul(h1 ^ c, 0x01000193); // FNV prime
191
+ h2 = Math.imul(h2 ^ c, 0x01000197); // distinct odd multiplier
192
+ }
193
+ return ((h1 >>> 0).toString(16).padStart(8, '0') +
194
+ (h2 >>> 0).toString(16).padStart(8, '0'));
195
+ }
196
+ function readHeader(headers, name) {
197
+ if (!headers)
198
+ return null;
199
+ if (headers instanceof HttpHeaders) {
200
+ const all = headers.getAll(name);
201
+ return all && all.length ? all.join(',') : null;
202
+ }
203
+ // record form — header names are case-insensitive
204
+ const lower = name.toLowerCase();
205
+ for (const key of Object.keys(headers)) {
206
+ if (key.toLowerCase() !== lower)
207
+ continue;
208
+ const value = headers[key];
209
+ if (value == null)
210
+ return null;
211
+ return Array.isArray(value) ? value.join(',') : String(value);
212
+ }
213
+ return null;
214
+ }
215
+ /**
216
+ * Content-negotiation headers whose values are low-entropy and non-identifying —
217
+ * embedded (URI-encoded) raw, keeping keys human-readable and skipping the digest.
218
+ * Anything NOT on this list (Authorization, api keys, tenant/x-* headers — we can't
219
+ * know what they carry) is one-way digested instead.
220
+ */
221
+ const SAFE_RAW_HEADERS = new Set([
222
+ 'accept',
223
+ 'accept-language',
224
+ 'content-language',
225
+ 'content-type',
226
+ ]);
227
+ const UNSAFE_HEADER_MESSAGES = new Map([
228
+ [
229
+ 'cookie',
230
+ "[@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.",
231
+ ],
232
+ [
233
+ 'set-cookie',
234
+ "[@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.",
235
+ ],
236
+ [
237
+ 'authorization',
238
+ "[@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.",
239
+ ],
240
+ [
241
+ 'x-request-id',
242
+ "[@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.",
243
+ ],
244
+ [
245
+ 'x-correlation-id',
246
+ "[@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.",
247
+ ],
248
+ [
249
+ 'if-none-match',
250
+ "[@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.",
251
+ ],
252
+ [
253
+ 'if-modified-since',
254
+ "[@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.",
255
+ ],
256
+ ]);
257
+ function normalizeVaryHeaders(headers, names) {
258
+ const isDev = isDevMode();
259
+ return names
260
+ .map((n) => n.toLowerCase())
261
+ .toSorted()
262
+ .map((name) => {
263
+ if (isDev) {
264
+ const warning = UNSAFE_HEADER_MESSAGES.get(name);
265
+ if (warning)
266
+ console.warn(warning);
267
+ }
268
+ const value = readHeader(headers, name);
269
+ if (value === null)
270
+ return `${name}=`;
271
+ // known-safe values raw (readable, cheap); everything else digested, NEVER raw —
272
+ // keys are persisted to IndexedDB and broadcast across tabs
273
+ return SAFE_RAW_HEADERS.has(name)
274
+ ? `${name}=${encodeURIComponent(value)}`
275
+ : `${name}=${digestHeaderValue(value)}`;
276
+ })
277
+ .join('&');
278
+ }
279
+ function normalizeParams(params) {
280
+ const p = params instanceof HttpParams
281
+ ? params
282
+ : new HttpParams({ fromObject: params });
283
+ return p
284
+ .keys()
285
+ .toSorted()
286
+ .map((key) => {
287
+ const encodedKey = encodeURIComponent(key);
288
+ return (p.getAll(key) ?? [])
289
+ .map((v) => `${encodedKey}=${encodeURIComponent(v)}`)
290
+ .join('&');
291
+ })
292
+ .join('&');
293
+ }
294
+ function hashBody(body) {
295
+ // File extends Blob — must check File first
296
+ if (typeof File !== 'undefined' && body instanceof File) {
297
+ return `File:${body.name}:${body.type}:${body.size}:${body.lastModified}`;
298
+ }
299
+ if (typeof Blob !== 'undefined' && body instanceof Blob) {
300
+ return `Blob:${body.type}:${body.size}`;
301
+ }
302
+ if (typeof FormData !== 'undefined' && body instanceof FormData) {
303
+ const entries = [];
304
+ body.forEach((value, key) => {
305
+ entries.push([key, hashBody(value)]);
306
+ });
307
+ entries.sort(([ak, av], [bk, bv]) => ak.localeCompare(bk) || av.localeCompare(bv));
308
+ return `FormData:${entries.map(([k, v]) => `${k}=${v}`).join('&')}`;
309
+ }
310
+ if (typeof URLSearchParams !== 'undefined' &&
311
+ body instanceof URLSearchParams) {
312
+ const sp = new URLSearchParams(body);
313
+ sp.sort();
314
+ return `URLSearchParams:${sp.toString()}`;
315
+ }
316
+ if (body instanceof ArrayBuffer) {
317
+ return `ArrayBuffer:${body.byteLength}`;
318
+ }
319
+ if (ArrayBuffer.isView(body)) {
320
+ return `${body.constructor.name}:${body.byteLength}`;
321
+ }
322
+ return hash(body);
323
+ }
324
+ /**
325
+ * Builds a stable cache/dedupe key from an HTTP request shape (accepts both
326
+ * `HttpRequest` and `HttpResourceRequest`).
327
+ *
328
+ * Key composition: `${method}␟${url}␟${responseType}[␟${params}][␟${body}][␟${vary}]`,
329
+ * where `␟` is {@link KEY_DELIMITER} (ASCII Unit Separator) — a content-rare top-level
330
+ * separator. Sub-fields inside `params`/`vary` keep their own `&`/`=` delimiters.
331
+ * - `method` defaults to `'GET'`, `responseType` to `'json'` (Angular defaults).
332
+ * - Query params are sorted alphabetically and URL-encoded for stability.
333
+ * - Body hashing handles `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`
334
+ * and typed arrays explicitly; everything else flows through key-sorted
335
+ * `JSON.stringify` via `hash()`.
336
+ * - `varyHeaders` (opt-in) mixes the named request headers into the key so responses
337
+ * that differ per header (e.g. `Authorization` → per-user, `Accept-Language`) get
338
+ * separate entries. Known-safe content-negotiation headers (`Accept`,
339
+ * `Accept-Language`, `Content-Language`, `Content-Type`) embed their value raw for
340
+ * readable keys; all other header VALUES are one-way digested, never embedded raw —
341
+ * keys are persisted to IndexedDB and broadcast across tabs.
342
+ */
343
+ function hashRequest(req, varyHeaders) {
344
+ const method = req.method ?? 'GET';
345
+ const responseType = req.responseType ?? 'json';
346
+ const base = `${method}${KEY_DELIMITER}${req.url}${KEY_DELIMITER}${responseType}`;
347
+ const params = req.params
348
+ ? `${KEY_DELIMITER}${normalizeParams(req.params)}`
349
+ : '';
350
+ const body = req.body != null ? `${KEY_DELIMITER}${hashBody(req.body)}` : '';
351
+ const vary = varyHeaders?.length
352
+ ? `${KEY_DELIMITER}vary(${normalizeVaryHeaders(req.headers, varyHeaders)})`
353
+ : '';
354
+ return base + params + body + vary;
355
+ }
356
+
45
357
  function createNoopDB() {
46
358
  return {
47
359
  getAll: async () => [],
@@ -178,6 +490,8 @@ class Cache {
178
490
  hydrated = false;
179
491
  /** Keys invalidated while hydration was still in flight — must not be resurrected by it. */
180
492
  hydrationTombstones = new Set();
493
+ /** Dev-only: ensures the "foreign keys, no matcher" hint in invalidateUrlPrefix fires at most once. */
494
+ warnedForeignKeys = false;
181
495
  hitCount = signal(0, ...(ngDevMode ? [{ debugName: "hitCount" }] : []));
182
496
  missCount = signal(0, ...(ngDevMode ? [{ debugName: "missCount" }] : []));
183
497
  /**
@@ -221,7 +535,7 @@ class Cache {
221
535
  };
222
536
  if (this.cleanupOpt.maxSize <= 0)
223
537
  throw new Error('maxSize must be greater than 0');
224
- // a non-finite checkInterval disables the sweeper entirely (used by the shared NoopCache)
538
+ // a non-finite checkInterval disables the sweeper entirely (used by provideMockQueryCache)
225
539
  const cleanupInterval = Number.isFinite(this.cleanupOpt.checkInterval)
226
540
  ? setInterval(() => {
227
541
  this.cleanup();
@@ -470,6 +784,55 @@ class Cache {
470
784
  invalidatePrefix(prefix) {
471
785
  return this.invalidateWhere((key) => key.startsWith(prefix));
472
786
  }
787
+ /**
788
+ * Invalidates every cache entry whose *request URL* starts with `urlPrefix`,
789
+ * regardless of HTTP method. This is the engine behind `mutationResource`'s
790
+ * `invalidates` option: `'/api/posts'` clears `/api/posts` with any query
791
+ * params, subpaths like `/api/posts/123`, and all `varyHeaders` variants —
792
+ * across GET/HEAD/OPTIONS/POST or any other cached method. Returns the number
793
+ * of entries removed.
794
+ *
795
+ * Unlike {@link invalidatePrefix} (which matches the raw key from its start),
796
+ * this extracts the URL field from the auto-generated key shape, so it is not
797
+ * fooled by the leading method token nor by a namespace a custom `cache.hash`
798
+ * prepends (e.g. `tenant:…`). Plain prefix matching still catches siblings
799
+ * sharing the prefix (`/api/posts-archive`) — pass `'/api/posts/'` to narrow.
800
+ *
801
+ * Keys produced by a custom `hash` that don't follow the auto shape won't be
802
+ * matched by the default; pass `match` to describe how a URL prefix maps onto
803
+ * your key format. In dev mode, if a default-matcher call removes nothing and
804
+ * every cached key is foreign-shaped, this logs a one-time hint pointing at the
805
+ * `match` escape hatch (a likely sign of a custom `hash` with no matcher wired up).
806
+ *
807
+ * @param urlPrefix - URL prefix to match.
808
+ * @param match - Optional custom matcher: given the prefix, returns a key predicate.
809
+ *
810
+ * @example
811
+ * cache.invalidateUrlPrefix('/api/posts');
812
+ * // custom key scheme:
813
+ * cache.invalidateUrlPrefix('/api/posts', (p) => (k) => k.includes(`|url=${p}`));
814
+ */
815
+ invalidateUrlPrefix(urlPrefix, match) {
816
+ if (match)
817
+ return this.invalidateWhere(match(urlPrefix));
818
+ let sawAutoKey = false;
819
+ const removed = this.invalidateWhere((key) => {
820
+ const url = extractUrlFromKey(key);
821
+ if (url === null)
822
+ return false; // foreign-shaped key
823
+ sawAutoKey = true;
824
+ return url.startsWith(urlPrefix);
825
+ });
826
+ if (isDevMode() &&
827
+ !this.warnedForeignKeys &&
828
+ removed === 0 &&
829
+ !sawAutoKey &&
830
+ untracked(this.internal).size > 0) {
831
+ this.warnedForeignKeys = true;
832
+ 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.`);
833
+ }
834
+ return removed;
835
+ }
473
836
  /**
474
837
  * Invalidates every cache entry whose key matches the predicate. Use for
475
838
  * arbitrary bulk invalidation that doesn't fit prefix matching (e.g.
@@ -542,7 +905,48 @@ class Cache {
542
905
  this.internal.set(new Map(keep));
543
906
  }
544
907
  }
545
- const CLIENT_CACHE_TOKEN = new InjectionToken('INTERNAL_CLIENT_CACHE');
908
+ const CLIENT_CACHE_TOKEN = new InjectionToken('INTERNAL_CLIENT_CACHE', {
909
+ // Memory-only default so a plain queryResource works with zero config. No
910
+ // IndexedDB / BroadcastChannel — keeps it SSR-safe and request-isolated under
911
+ // SSR (root injector is per-request). provideQueryCache() overrides this to
912
+ // layer on persistence / cross-tab sync / global TTL tuning.
913
+ providedIn: 'root',
914
+ factory: () => {
915
+ const cache = new Cache();
916
+ inject(DestroyRef, { optional: true })?.onDestroy(() => cache.destroy());
917
+ return cache;
918
+ },
919
+ });
920
+ /**
921
+ * Provides a deterministic, in-memory `QueryCache` for unit tests.
922
+ *
923
+ * Unlike {@link provideQueryCache} this never touches IndexedDB or BroadcastChannel
924
+ * and disables the cleanup sweep interval (`checkInterval: Infinity`), so it plays
925
+ * nicely with `vi.useFakeTimers()`. It's a real cache (not a stub), so you can
926
+ * assert cache hits via {@link injectQueryCache} / its `stats` signal.
927
+ *
928
+ * @example
929
+ * TestBed.configureTestingModule({
930
+ * providers: [
931
+ * provideMockQueryCache(),
932
+ * provideHttpClient(withInterceptors([createCacheInterceptor()])),
933
+ * ],
934
+ * });
935
+ */
936
+ function provideMockQueryCache(opt) {
937
+ return {
938
+ provide: CLIENT_CACHE_TOKEN,
939
+ useFactory: () => {
940
+ const cache = new Cache(opt?.ttl, opt?.staleTime, {
941
+ type: 'lru',
942
+ maxSize: 200,
943
+ checkInterval: Infinity,
944
+ });
945
+ inject(DestroyRef, { optional: true })?.onDestroy(() => cache.destroy());
946
+ return cache;
947
+ },
948
+ };
949
+ }
546
950
  /**
547
951
  * Provides the instance of the QueryCache for queryResource. This should probably be called
548
952
  * in your application's root configuration, but can also be overriden with component/module providers.
@@ -605,399 +1009,89 @@ function provideQueryCache(opt) {
605
1009
  return null;
606
1010
  }
607
1011
  };
608
- // version-suffixed so two deploys with incompatible schemas in adjacent tabs don't
609
- // push entries into each other's caches (the `version` option only fences IndexedDB)
610
1012
  const syncChannelId = `mmstack-query-cache-sync_v${opt?.version ?? 1}`;
611
1013
  return {
612
1014
  provide: CLIENT_CACHE_TOKEN,
613
1015
  useFactory: () => {
614
1016
  const onServer = inject(PLATFORM_ID) === 'server';
615
- // no IndexedDB / BroadcastChannel on the server — each request gets an
616
- // isolated, request-lived, memory-only cache
1017
+ // no IndexedDB / BroadcastChannel on the server
617
1018
  const syncTabsOpt = !onServer && opt?.syncTabs
618
1019
  ? {
619
1020
  id: syncChannelId,
620
1021
  serialize,
621
- deserialize,
622
- }
623
- : undefined;
624
- const db = onServer || opt?.persist === false
625
- ? undefined
626
- : createSingleStoreDB('mmstack-query-cache-db', (version) => `query-store_v${version}`, opt?.version).then((db) => {
627
- return {
628
- getAll: () => {
629
- return db.getAll().then((entries) => {
630
- return entries
631
- .map((entry) => {
632
- const value = deserialize(entry.value);
633
- if (value === null)
634
- return null;
635
- return {
636
- ...entry,
637
- value,
638
- };
639
- })
640
- .filter((e) => e !== null);
641
- });
642
- },
643
- store: (entry) => {
644
- return db.store({ ...entry, value: serialize(entry.value) });
645
- },
646
- remove: db.remove,
647
- };
648
- });
649
- const cache = new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup, syncTabsOpt, db);
650
- // release the sweep interval / channel with the providing injector
651
- inject(DestroyRef, { optional: true })?.onDestroy(() => cache.destroy());
652
- return cache;
653
- },
654
- };
655
- }
656
- class NoopCache extends Cache {
657
- constructor() {
658
- // Infinity checkInterval → no sweep interval is ever armed, so the shared
659
- // instance below never pins a timer
660
- super(undefined, undefined, {
661
- type: 'lru',
662
- maxSize: 200,
663
- checkInterval: Infinity,
664
- });
665
- }
666
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
667
- store(_, __, ___ = super.staleTime, ____ = super.ttl) {
668
- // noop
669
- }
670
- }
671
- // one shared instance — minting a NoopCache per injectQueryCache() miss would leak
672
- // an instance (and previously an interval) on every prod call without a provider
673
- let NOOP_CACHE;
674
- /**
675
- * Injects the `QueryCache` instance that is used within queryResource.
676
- * Allows for direct modification of cached data, but is mostly meant for internal use.
677
- *
678
- * @param injector - (Optional) The injector to use. If not provided, the current
679
- * injection context is used.
680
- * @returns The `QueryCache` instance.
681
- *
682
- * @example
683
- * // In your component or service:
684
- *
685
- * import { injectQueryCache } from './your-cache';
686
- *
687
- * constructor() {
688
- * const cache = injectQueryCache();
689
- *
690
- * const myData = cache.get(() => 'my-data-key');
691
- * if (myData() !== null) {
692
- * // ... use cached data ...
693
- * }
694
- * }
695
- */
696
- function injectQueryCache(injector) {
697
- const cache = injector
698
- ? injector.get(CLIENT_CACHE_TOKEN, null, {
699
- optional: true,
700
- })
701
- : inject(CLIENT_CACHE_TOKEN, {
702
- optional: true,
703
- });
704
- if (!cache) {
705
- if (isDevMode())
706
- throw new Error('Cache not provided, please add provideQueryCache() to providers array');
707
- else
708
- return (NOOP_CACHE ??= new NoopCache());
709
- }
710
- return cache;
711
- }
712
- /**
713
- * Injects the cache statistics, including the current size of the cache and the number of hits and misses.
714
- *
715
- * @param injector - (Optional) The injector to use. If not provided, the current
716
- * injection context is used.
717
- * @returns A signal containing the cache statistics.
718
- */
719
- function injectCacheStats(injector) {
720
- const cache = injectQueryCache(injector);
721
- return cache.stats;
722
- }
723
-
724
- /**
725
- * Returns `true` for any object-like value whose own enumerable keys should
726
- * be sorted for stable hashing. Excludes arrays (positional), `Date`
727
- * (handled by `toJSON`), `Map`/`Set` (handled explicitly), and binary types
728
- * (`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`/typed arrays — these
729
- * should be branched on before reaching `hash()`, typically by `hashRequest`).
730
- *
731
- * Plain objects, class instances, and `Object.create(null)` all qualify.
732
- */
733
- function isHashableObject(value) {
734
- if (value === null || typeof value !== 'object')
735
- return false;
736
- if (Array.isArray(value))
737
- return false;
738
- if (value instanceof Date)
739
- return false;
740
- if (value instanceof Map)
741
- return false;
742
- if (value instanceof Set)
743
- return false;
744
- if (typeof Blob !== 'undefined' && value instanceof Blob)
745
- return false;
746
- if (typeof FormData !== 'undefined' && value instanceof FormData)
747
- return false;
748
- if (typeof URLSearchParams !== 'undefined' &&
749
- value instanceof URLSearchParams)
750
- return false;
751
- if (value instanceof ArrayBuffer)
752
- return false;
753
- if (ArrayBuffer.isView(value))
754
- return false;
755
- return true;
756
- }
757
- function sortKeys(val) {
758
- return Object.keys(val)
759
- .toSorted()
760
- .reduce((result, key) => {
761
- result[key] = val[key];
762
- return result;
763
- }, {});
764
- }
765
- /**
766
- * Internal helper to generate a stable JSON string from an array.
767
- * - Object-like values (plain, class instances, null-proto) get their own
768
- * enumerable keys sorted alphabetically.
769
- * - `Map` → marker object with sorted entries (sorted by `JSON.stringify(key)`).
770
- * - `Set` → marker object with sorted values (sorted by `JSON.stringify(value)`).
771
- * - Arrays preserve order. `Date` serializes via `toJSON`.
772
- *
773
- * @internal
774
- */
775
- function hashKey(queryKey) {
776
- return JSON.stringify(queryKey, (_, val) => {
777
- if (val instanceof Map) {
778
- // Schwartzian: compute each entry's sort key (recursive hash of the
779
- // Map key) once, then sort by the cheap string compare.
780
- const entries = [...val.entries()]
781
- .map((e) => [hash(e[0]), e])
782
- .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
783
- .map(([, e]) => e);
784
- return { __map__: entries };
785
- }
786
- if (val instanceof Set) {
787
- const values = [...val]
788
- .map((v) => [hash(v), v])
789
- .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
790
- .map(([, v]) => v);
791
- return { __set__: values };
792
- }
793
- if (isHashableObject(val))
794
- return sortKeys(val);
795
- return val;
796
- });
1022
+ deserialize,
1023
+ }
1024
+ : undefined;
1025
+ const db = onServer || opt?.persist === false
1026
+ ? undefined
1027
+ : createSingleStoreDB('mmstack-query-cache-db', (version) => `query-store_v${version}`, opt?.version).then((db) => {
1028
+ return {
1029
+ getAll: () => {
1030
+ return db.getAll().then((entries) => {
1031
+ return entries
1032
+ .map((entry) => {
1033
+ const value = deserialize(entry.value);
1034
+ if (value === null)
1035
+ return null;
1036
+ return {
1037
+ ...entry,
1038
+ value,
1039
+ };
1040
+ })
1041
+ .filter((e) => e !== null);
1042
+ });
1043
+ },
1044
+ store: (entry) => {
1045
+ return db.store({ ...entry, value: serialize(entry.value) });
1046
+ },
1047
+ remove: db.remove,
1048
+ };
1049
+ });
1050
+ const cache = new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup, syncTabsOpt, db);
1051
+ // release the sweep interval / channel with the providing injector
1052
+ inject(DestroyRef, { optional: true })?.onDestroy(() => cache.destroy());
1053
+ return cache;
1054
+ },
1055
+ };
797
1056
  }
798
1057
  /**
799
- * Generates a stable, unique string hash from one or more arguments.
800
- * Useful for creating cache keys or identifiers where object key order shouldn't matter.
1058
+ * Injects the `QueryCache` instance that is used within queryResource.
1059
+ * Allows for direct modification of cached data, but is mostly meant for internal use.
801
1060
  *
802
- * How it works:
803
- * - Object-like values (plain objects, class instances, `Object.create(null)`) have
804
- * their own enumerable keys sorted alphabetically before hashing. This ensures
805
- * `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same hash.
806
- * - `Map` and `Set` are serialized via stable, sorted markers (`__map__` / `__set__`).
807
- * - Arrays preserve positional order; `Date` uses its ISO string via `toJSON`.
1061
+ * @param injector - (Optional) The injector to use. If not provided, the current
1062
+ * injection context is used.
1063
+ * @returns The `QueryCache` instance.
808
1064
  *
809
- * @param {...unknown} args Values to include in the hash.
810
- * @returns A stable string hash representing the input arguments.
811
1065
  * @example
812
- * hash('posts', 10);
813
- * // => '["posts",10]'
1066
+ * // In your component or service:
814
1067
  *
815
- * hash({ a: 1, b: 2 }) === hash({ b: 2, a: 1 }); // true
1068
+ * import { injectQueryCache } from './your-cache';
816
1069
  *
817
- * hash(new Map([['a', 1]])) === hash(new Map([['a', 1]])); // true
1070
+ * constructor() {
1071
+ * const cache = injectQueryCache();
818
1072
  *
819
- * // Be mindful of values JSON.stringify cannot handle (functions, undefined, Symbols)
820
- * // hash('a', undefined, function() {}) => '["a",null,null]'
821
- */
822
- function hash(...args) {
823
- return hashKey(args);
824
- }
825
-
826
- /**
827
- * @internal
828
- * One-way ~64-bit digest from two independent FNV-1a passes. Used for header VALUES in
829
- * cache keys: keys are persisted (IndexedDB) and broadcast cross-tab, so raw values
830
- * (auth tokens!) must never appear in them. A single 32-bit digest's 2^-32 collision
831
- * chance is too thin at a security boundary — two colliding tokens would serve one
832
- * user's cached data under another user's key; 64 bits puts collisions out of reach.
833
- * High-entropy secrets are not recoverable from the digest.
834
- */
835
- function digestHeaderValue(value) {
836
- let h1 = 0x811c9dc5; // FNV-1a offset basis
837
- let h2 = 0xcbf29ce4; // independent second pass
838
- for (let i = 0; i < value.length; i++) {
839
- const c = value.charCodeAt(i);
840
- h1 = Math.imul(h1 ^ c, 0x01000193); // FNV prime
841
- h2 = Math.imul(h2 ^ c, 0x01000197); // distinct odd multiplier
842
- }
843
- return ((h1 >>> 0).toString(16).padStart(8, '0') +
844
- (h2 >>> 0).toString(16).padStart(8, '0'));
845
- }
846
- function readHeader(headers, name) {
847
- if (!headers)
848
- return null;
849
- if (headers instanceof HttpHeaders) {
850
- const all = headers.getAll(name);
851
- return all && all.length ? all.join(',') : null;
852
- }
853
- // record form — header names are case-insensitive
854
- const lower = name.toLowerCase();
855
- for (const key of Object.keys(headers)) {
856
- if (key.toLowerCase() !== lower)
857
- continue;
858
- const value = headers[key];
859
- if (value == null)
860
- return null;
861
- return Array.isArray(value) ? value.join(',') : String(value);
862
- }
863
- return null;
864
- }
865
- /**
866
- * Content-negotiation headers whose values are low-entropy and non-identifying —
867
- * embedded (URI-encoded) raw, keeping keys human-readable and skipping the digest.
868
- * Anything NOT on this list (Authorization, api keys, tenant/x-* headers — we can't
869
- * know what they carry) is one-way digested instead.
1073
+ * const myData = cache.get(() => 'my-data-key');
1074
+ * if (myData() !== null) {
1075
+ * // ... use cached data ...
1076
+ * }
1077
+ * }
870
1078
  */
871
- const SAFE_RAW_HEADERS = new Set([
872
- 'accept',
873
- 'accept-language',
874
- 'content-language',
875
- 'content-type',
876
- ]);
877
- const UNSAFE_HEADER_MESSAGES = new Map([
878
- [
879
- 'cookie',
880
- "[@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.",
881
- ],
882
- [
883
- 'set-cookie',
884
- "[@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.",
885
- ],
886
- [
887
- 'authorization',
888
- "[@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.",
889
- ],
890
- [
891
- 'x-request-id',
892
- "[@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.",
893
- ],
894
- [
895
- 'x-correlation-id',
896
- "[@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.",
897
- ],
898
- [
899
- 'if-none-match',
900
- "[@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.",
901
- ],
902
- [
903
- 'if-modified-since',
904
- "[@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.",
905
- ],
906
- ]);
907
- function normalizeVaryHeaders(headers, names) {
908
- const isDev = isDevMode();
909
- return names
910
- .map((n) => n.toLowerCase())
911
- .toSorted()
912
- .map((name) => {
913
- if (isDev) {
914
- const warning = UNSAFE_HEADER_MESSAGES.get(name);
915
- if (warning)
916
- console.warn(warning);
917
- }
918
- const value = readHeader(headers, name);
919
- if (value === null)
920
- return `${name}=`;
921
- // known-safe values raw (readable, cheap); everything else digested, NEVER raw —
922
- // keys are persisted to IndexedDB and broadcast across tabs
923
- return SAFE_RAW_HEADERS.has(name)
924
- ? `${name}=${encodeURIComponent(value)}`
925
- : `${name}=${digestHeaderValue(value)}`;
926
- })
927
- .join('&');
928
- }
929
- function normalizeParams(params) {
930
- const p = params instanceof HttpParams
931
- ? params
932
- : new HttpParams({ fromObject: params });
933
- return p
934
- .keys()
935
- .toSorted()
936
- .map((key) => {
937
- const encodedKey = encodeURIComponent(key);
938
- return (p.getAll(key) ?? [])
939
- .map((v) => `${encodedKey}=${encodeURIComponent(v)}`)
940
- .join('&');
941
- })
942
- .join('&');
943
- }
944
- function hashBody(body) {
945
- // File extends Blob — must check File first
946
- if (typeof File !== 'undefined' && body instanceof File) {
947
- return `File:${body.name}:${body.type}:${body.size}:${body.lastModified}`;
948
- }
949
- if (typeof Blob !== 'undefined' && body instanceof Blob) {
950
- return `Blob:${body.type}:${body.size}`;
951
- }
952
- if (typeof FormData !== 'undefined' && body instanceof FormData) {
953
- const entries = [];
954
- body.forEach((value, key) => {
955
- entries.push([key, hashBody(value)]);
956
- });
957
- entries.sort(([ak, av], [bk, bv]) => ak.localeCompare(bk) || av.localeCompare(bv));
958
- return `FormData:${entries.map(([k, v]) => `${k}=${v}`).join('&')}`;
959
- }
960
- if (typeof URLSearchParams !== 'undefined' &&
961
- body instanceof URLSearchParams) {
962
- const sp = new URLSearchParams(body);
963
- sp.sort();
964
- return `URLSearchParams:${sp.toString()}`;
965
- }
966
- if (body instanceof ArrayBuffer) {
967
- return `ArrayBuffer:${body.byteLength}`;
968
- }
969
- if (ArrayBuffer.isView(body)) {
970
- return `${body.constructor.name}:${body.byteLength}`;
971
- }
972
- return hash(body);
1079
+ function injectQueryCache(injector) {
1080
+ const cache = injector
1081
+ ? injector.get(CLIENT_CACHE_TOKEN)
1082
+ : inject(CLIENT_CACHE_TOKEN);
1083
+ return cache;
973
1084
  }
974
1085
  /**
975
- * Builds a stable cache/dedupe key from an HTTP request shape (accepts both
976
- * `HttpRequest` and `HttpResourceRequest`).
1086
+ * Injects the cache statistics, including the current size of the cache and the number of hits and misses.
977
1087
  *
978
- * Key composition: `${method}:${url}:${responseType}[:${params}][:${body}][:${vary}]`
979
- * - `method` defaults to `'GET'`, `responseType` to `'json'` (Angular defaults).
980
- * - Query params are sorted alphabetically and URL-encoded for stability.
981
- * - Body hashing handles `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`
982
- * and typed arrays explicitly; everything else flows through key-sorted
983
- * `JSON.stringify` via `hash()`.
984
- * - `varyHeaders` (opt-in) mixes the named request headers into the key so responses
985
- * that differ per header (e.g. `Authorization` → per-user, `Accept-Language`) get
986
- * separate entries. Known-safe content-negotiation headers (`Accept`,
987
- * `Accept-Language`, `Content-Language`, `Content-Type`) embed their value raw for
988
- * readable keys; all other header VALUES are one-way digested, never embedded raw —
989
- * keys are persisted to IndexedDB and broadcast across tabs.
1088
+ * @param injector - (Optional) The injector to use. If not provided, the current
1089
+ * injection context is used.
1090
+ * @returns A signal containing the cache statistics.
990
1091
  */
991
- function hashRequest(req, varyHeaders) {
992
- const method = req.method ?? 'GET';
993
- const responseType = req.responseType ?? 'json';
994
- const base = `${method}:${req.url}:${responseType}`;
995
- const params = req.params ? `:${normalizeParams(req.params)}` : '';
996
- const body = req.body != null ? `:${hashBody(req.body)}` : '';
997
- const vary = varyHeaders?.length
998
- ? `:vary(${normalizeVaryHeaders(req.headers, varyHeaders)})`
999
- : '';
1000
- return base + params + body + vary;
1092
+ function injectCacheStats(injector) {
1093
+ const cache = injectQueryCache(injector);
1094
+ return cache.stats;
1001
1095
  }
1002
1096
 
1003
1097
  /**
@@ -1893,6 +1987,33 @@ function injectNetworkStatus() {
1893
1987
  function injectPageVisibility() {
1894
1988
  return inject(ResourceSensors).pageVisibility;
1895
1989
  }
1990
+ /**
1991
+ * Provides controllable {@link ResourceSensors} for unit tests, letting you drive a
1992
+ * resource's offline / page-hidden behavior deterministically instead of relying on
1993
+ * the real `navigator.onLine` / `document.visibilityState`.
1994
+ *
1995
+ * Pass your own writable signals to toggle state mid-test; omit them for a static
1996
+ * online + visible environment.
1997
+ *
1998
+ * @example
1999
+ * import { signal } from '@angular/core';
2000
+ *
2001
+ * const online = signal(true);
2002
+ * TestBed.configureTestingModule({
2003
+ * providers: [provideMockResourceSensors({ networkStatus: online })],
2004
+ * });
2005
+ * // ...later in the test
2006
+ * online.set(false); // the resource now sees the network as down
2007
+ */
2008
+ function provideMockResourceSensors(opt) {
2009
+ return {
2010
+ provide: ResourceSensors,
2011
+ useValue: {
2012
+ networkStatus: opt?.networkStatus ?? signal(true),
2013
+ pageVisibility: opt?.pageVisibility ?? signal('visible'),
2014
+ },
2015
+ };
2016
+ }
1896
2017
 
1897
2018
  function toResourceObject(res) {
1898
2019
  return {
@@ -2441,9 +2562,7 @@ function mutationResource(request, options0 = {}) {
2441
2562
  circuitBreaker: mergeCircuitBreakerOptions(globalOpts.circuitBreaker, mutOpts.circuitBreaker, options0?.circuitBreaker),
2442
2563
  retry: mergeRetryOptions(globalOpts.retry, mutOpts.retry, options0?.retry),
2443
2564
  };
2444
- // `register` is pulled out (and forced off on the inner query below) so the mutation ref is
2445
- // the only thing registered into the transition scope, not its internal query resource.
2446
- const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, invalidates, ...rest } = options;
2565
+ const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, invalidates, invalidateMatcher, ...rest } = options;
2447
2566
  const cache = invalidates ? injectQueryCache(options.injector) : undefined;
2448
2567
  const requestEqual = equalRequest ?? createEqualRequest(equal);
2449
2568
  const triggerOnSame = options.triggerOnSameRequest ?? false;
@@ -2646,7 +2765,7 @@ function mutationResource(request, options0 = {}) {
2646
2765
  ? invalidates(result.value, (mutation === NULL_VALUE ? undefined : mutation))
2647
2766
  : invalidates;
2648
2767
  for (const prefix of prefixes)
2649
- cache.invalidatePrefix(`GET:${prefix}`);
2768
+ cache.invalidateUrlPrefix(prefix, invalidateMatcher);
2650
2769
  }
2651
2770
  deferred?.resolve(result.value);
2652
2771
  }
@@ -2711,5 +2830,5 @@ function mutationResource(request, options0 = {}) {
2711
2830
  * Generated bundle index. Do not edit.
2712
2831
  */
2713
2832
 
2714
- export { Cache, MutationCancelledError, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, hashRequest, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
2833
+ export { Cache, MutationCancelledError, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, hashRequest, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMockQueryCache, provideMockResourceSensors, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
2715
2834
  //# sourceMappingURL=mmstack-resource.mjs.map