@mmstack/resource 20.8.1 → 20.8.3

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,8 +1,8 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, inject, runInInjectionContext, DestroyRef, isDevMode, untracked, computed, signal, effect, Injector, Injectable, linkedSignal } from '@angular/core';
3
- import { injectTransitionScope, mutable, toWritable, keepPrevious, sensor, nestedEffect } from '@mmstack/primitives';
2
+ import { InjectionToken, inject, runInInjectionContext, DestroyRef, isDevMode, signal, computed, untracked, PLATFORM_ID, effect, Injector, Injectable, linkedSignal } from '@angular/core';
4
3
  import { HttpHeaders, HttpResponse, HttpParams, HttpContextToken, HttpContext, httpResource, HttpClient } from '@angular/common/http';
5
- import { of, tap, map, finalize, shareReplay, interval, firstValueFrom, catchError, combineLatestWith, filter } from 'rxjs';
4
+ import { injectTransitionScope, mutable, toWritable, keepPrevious, sensor, injectPaused, nestedEffect } from '@mmstack/primitives';
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
8
  const RESOURCE_OPTIONS = new InjectionToken('@mmstack/resource:resource-options', { factory: () => ({}) });
@@ -62,6 +62,8 @@ function toCacheDB(db, storeName) {
62
62
  const request = store.getAll();
63
63
  request.onsuccess = () => res(request.result);
64
64
  request.onerror = () => rej(request.error);
65
+ // some browsers abort (rather than error) e.g. on quota issues — without this the promise would stay pending forever
66
+ transaction.onabort = () => rej(transaction.error);
65
67
  })
66
68
  .then((entries) => entries.filter((e) => e.expiresAt > now))
67
69
  .catch((err) => {
@@ -77,6 +79,8 @@ function toCacheDB(db, storeName) {
77
79
  store.put(value);
78
80
  transaction.oncomplete = () => res();
79
81
  transaction.onerror = () => rej(transaction.error);
82
+ // QuotaExceededError surfaces as an abort in some browsers
83
+ transaction.onabort = () => rej(transaction.error);
80
84
  }).catch((err) => {
81
85
  if (isDevMode())
82
86
  console.error('Error storing item in cache DB:', err);
@@ -89,6 +93,7 @@ function toCacheDB(db, storeName) {
89
93
  store.delete(key);
90
94
  transaction.oncomplete = () => res();
91
95
  transaction.onerror = () => rej(transaction.error);
96
+ transaction.onabort = () => rej(transaction.error);
92
97
  }).catch((err) => {
93
98
  if (isDevMode())
94
99
  console.error('Error removing item from cache DB:', err);
@@ -105,8 +110,10 @@ function createSingleStoreDB(name, getStoreName, version = 1) {
105
110
  if (!globalThis.indexedDB)
106
111
  return Promise.resolve(createNoopDB());
107
112
  return new Promise((res, rej) => {
108
- if (version < 1)
113
+ if (version < 1) {
109
114
  rej(new Error('Version must be 1 or greater'));
115
+ return; // rej does not stop execution — without this, indexedDB.open(name, 0) still runs
116
+ }
110
117
  const req = indexedDB.open(name, version);
111
118
  req.onupgradeneeded = (event) => {
112
119
  const db = req.result;
@@ -141,6 +148,13 @@ function isSyncMessage(msg) {
141
148
  'type' in msg &&
142
149
  msg.type === 'cache-sync-message');
143
150
  }
151
+ /**
152
+ * setTimeout coerces its delay through a signed 32-bit conversion: `Infinity` becomes 0
153
+ * (immediate!) and anything above 2^31-1 ms (~24.8 days) wraps negative. Entries beyond
154
+ * this bound get NO timer and rely on lazy expiry (`expiresAt <= now` checks) plus the
155
+ * periodic sweep instead.
156
+ */
157
+ const MAX_TIMER_DELAY = 2 ** 31 - 1;
144
158
  const ONE_DAY = 1000 * 60 * 60 * 24;
145
159
  const ONE_HOUR = 1000 * 60 * 60;
146
160
  const DEFAULT_CLEANUP_OPT = {
@@ -160,9 +174,27 @@ class Cache {
160
174
  internal = mutable(new Map());
161
175
  cleanupOpt;
162
176
  id = generateID();
177
+ /** True once async hydration from the persistence layer has completed (or was empty). */
178
+ hydrated = false;
179
+ /** Keys invalidated while hydration was still in flight — must not be resurrected by it. */
180
+ hydrationTombstones = new Set();
181
+ hitCount = signal(0, ...(ngDevMode ? [{ debugName: "hitCount" }] : []));
182
+ missCount = signal(0, ...(ngDevMode ? [{ debugName: "missCount" }] : []));
163
183
  /**
164
- * Destroys the cache instance, cleaning up any resources used by the cache.
165
- * This method is called automatically when the cache instance is garbage collected.
184
+ * Read-only cache statistics for debugging/observability entry count plus
185
+ * request-level hit/miss counters (counted on direct lookups, e.g. the cache
186
+ * interceptor's, not on every reactive signal read). Render it in a debug
187
+ * panel; it intentionally exposes no way to mutate the cache.
188
+ */
189
+ stats = computed(() => ({
190
+ size: this.internal().size,
191
+ hits: this.hitCount(),
192
+ misses: this.missCount(),
193
+ }), ...(ngDevMode ? [{ debugName: "stats" }] : []));
194
+ /**
195
+ * Destroys the cache instance, clearing the cleanup interval and closing the
196
+ * cross-tab channel. Called automatically when the providing injector is destroyed
197
+ * (wired up by `provideQueryCache`); call it manually for caches you construct yourself.
166
198
  */
167
199
  destroy;
168
200
  broadcast = () => {
@@ -179,11 +211,7 @@ class Cache {
179
211
  * @param syncTabs - If provided, the cache will use the options a BroadcastChannel to send updates between tabs.
180
212
  * Defaults to `undefined`, meaning no synchronization across tabs.
181
213
  */
182
- constructor(ttl = ONE_DAY, staleTime = ONE_HOUR, cleanupOpt = {
183
- type: 'lru',
184
- maxSize: 1000,
185
- checkInterval: ONE_HOUR,
186
- }, syncTabs, db = Promise.resolve(createNoopDB())) {
214
+ constructor(ttl = ONE_DAY, staleTime = ONE_HOUR, cleanupOpt = DEFAULT_CLEANUP_OPT, syncTabs, db = Promise.resolve(createNoopDB())) {
187
215
  this.ttl = ttl;
188
216
  this.staleTime = staleTime;
189
217
  this.db = db;
@@ -193,10 +221,12 @@ class Cache {
193
221
  };
194
222
  if (this.cleanupOpt.maxSize <= 0)
195
223
  throw new Error('maxSize must be greater than 0');
196
- // cleanup cache based on provided options regularly
197
- const cleanupInterval = setInterval(() => {
198
- this.cleanup();
199
- }, cleanupOpt.checkInterval);
224
+ // a non-finite checkInterval disables the sweeper entirely (used by the shared NoopCache)
225
+ const cleanupInterval = Number.isFinite(this.cleanupOpt.checkInterval)
226
+ ? setInterval(() => {
227
+ this.cleanup();
228
+ }, this.cleanupOpt.checkInterval)
229
+ : undefined;
200
230
  let destroySyncTabs = () => {
201
231
  // noop
202
232
  };
@@ -230,13 +260,11 @@ class Cache {
230
260
  const value = syncTabs.deserialize(msg.entry.value);
231
261
  if (value === null)
232
262
  return;
233
- // Last-write-wins by `updated` timestamp. If our local entry was
234
- // written more recently than the broadcast we just received, the
235
- // broadcast is stale (in-flight when we wrote locally) — drop it.
263
+ // Last-write-wins by `updated` timestamp.
236
264
  const existing = untracked(this.internal).get(msg.entry.key);
237
265
  if (existing && existing.updated >= msg.entry.updated)
238
266
  return;
239
- this.storeInternal(msg.entry.key, value, msg.entry.stale - msg.entry.updated, msg.entry.expiresAt - msg.entry.updated, true, false);
267
+ this.restoreInternal({ ...msg.entry, value });
240
268
  }
241
269
  else if (msg.action === 'invalidate') {
242
270
  this.invalidateInternal(msg.entry.key, true);
@@ -251,7 +279,8 @@ class Cache {
251
279
  if (destroyed)
252
280
  return;
253
281
  destroyed = true;
254
- clearInterval(cleanupInterval);
282
+ if (cleanupInterval !== undefined)
283
+ clearInterval(cleanupInterval);
255
284
  destroySyncTabs();
256
285
  };
257
286
  this.db
@@ -263,22 +292,19 @@ class Cache {
263
292
  .then((entries) => {
264
293
  if (destroyed)
265
294
  return;
266
- // load entries into the cache
267
295
  const current = untracked(this.internal);
268
296
  entries.forEach((entry) => {
269
297
  if (current.has(entry.key))
270
298
  return;
271
- this.storeInternal(entry.key, entry.value, entry.stale - entry.updated, entry.expiresAt - entry.updated, true);
299
+ // a key invalidated while hydration was in flight must stay dead
300
+ if (this.hydrationTombstones.has(entry.key))
301
+ return;
302
+ this.restoreInternal(entry);
272
303
  });
304
+ this.hydrated = true;
305
+ this.hydrationTombstones.clear();
273
306
  });
274
307
  this.destroy = destroy;
275
- // cleanup if object is garbage collected, this is because the cache can be quite large from a memory standpoint & we dont want all that floating garbage
276
- const registry = new FinalizationRegistry((id) => {
277
- if (id === this.id) {
278
- destroy();
279
- }
280
- });
281
- registry.register(this, this.id);
282
308
  }
283
309
  /** @internal */
284
310
  getInternal(key) {
@@ -291,22 +317,45 @@ class Cache {
291
317
  const now = Date.now();
292
318
  if (!found || found.expiresAt <= now)
293
319
  return null;
294
- found.useCount++;
295
320
  return {
296
321
  ...found,
297
322
  isStale: found.stale <= now,
298
323
  };
324
+ }, {
325
+ equal: (a, b) => a === b ||
326
+ (!!a &&
327
+ !!b &&
328
+ a.key === b.key &&
329
+ a.value === b.value &&
330
+ a.updated === b.updated &&
331
+ a.isStale === b.isStale),
299
332
  });
300
333
  }
334
+ /** @internal Imperative access bookkeeping for LRU eviction. */
335
+ touch(entry) {
336
+ entry.lastAccessed = Date.now();
337
+ entry.useCount++;
338
+ }
301
339
  /**
302
- * Retrieves a cache entry without affecting its usage count (for LRU). This is primarily
303
- * for internal use or debugging.
340
+ * Retrieves a cache entry directly (non-reactively), updating its access bookkeeping
341
+ * for LRU eviction.
304
342
  * @internal
305
343
  * @param key - The key of the entry to retrieve.
306
344
  * @returns The cache entry, or `null` if not found or expired.
307
345
  */
308
346
  getUntracked(key) {
309
- return untracked(this.getInternal(() => key));
347
+ const found = untracked(this.internal).get(key);
348
+ const now = Date.now();
349
+ if (!found || found.expiresAt <= now) {
350
+ this.missCount.update((c) => c + 1);
351
+ return null;
352
+ }
353
+ this.touch(found);
354
+ this.hitCount.update((c) => c + 1);
355
+ return {
356
+ ...found,
357
+ isStale: found.stale <= now,
358
+ };
310
359
  }
311
360
  /**
312
361
  * Retrieves a cache entry as a signal.
@@ -332,38 +381,65 @@ class Cache {
332
381
  /**
333
382
  * Stores a value in the cache.
334
383
  *
384
+ * NOTE: cached values are shared by reference across all consumers (current and
385
+ * future cache hits, persistence, cross-tab sync) — do not mutate a value after
386
+ * storing it or after reading it from the cache.
387
+ *
335
388
  * @param key - The key under which to store the value.
336
389
  * @param value - The value to store.
337
390
  * @param staleTime - (Optional) The stale time for this entry, in milliseconds. Overrides the default `staleTime`.
338
391
  * @param ttl - (Optional) The TTL for this entry, in milliseconds. Overrides the default `ttl`.
392
+ * @param persist - (Optional) Whether to also write the entry to the persistence layer (IndexedDB). Defaults to `false`.
339
393
  */
340
394
  store(key, value, staleTime = this.staleTime, ttl = this.ttl, persist = false) {
341
395
  this.storeInternal(key, value, staleTime, ttl, false, persist);
342
396
  }
343
397
  storeInternal(key, value, staleTime = this.staleTime, ttl = this.ttl, fromSync = false, persist = false) {
344
- const entry = this.getUntracked(key);
345
- if (entry) {
346
- clearTimeout(entry.timeout); // stop invalidation
347
- }
348
- const prevCount = entry?.useCount ?? 0;
398
+ const entry = untracked(this.internal).get(key);
349
399
  // ttl cannot be less than staleTime
350
400
  if (ttl < staleTime)
351
401
  staleTime = ttl;
352
402
  const now = Date.now();
353
- const next = {
403
+ this.setEntry({
354
404
  value,
355
405
  created: entry?.created ?? now,
356
406
  updated: now,
357
- useCount: prevCount + 1,
407
+ useCount: (entry?.useCount ?? 0) + 1,
408
+ lastAccessed: now,
358
409
  stale: now + staleTime,
359
410
  expiresAt: now + ttl,
360
411
  key,
361
- };
412
+ }, fromSync, persist);
413
+ }
414
+ /**
415
+ * @internal
416
+ * Inserts an entry that already carries ABSOLUTE timestamps — hydration from the
417
+ * persistence layer and cross-tab sync messages. Never re-anchors freshness to
418
+ * `Date.now()`, never persists, never broadcasts.
419
+ */
420
+ restoreInternal(entry) {
421
+ this.setEntry({
422
+ ...entry,
423
+ // rows persisted by older versions may lack the field
424
+ lastAccessed: entry.lastAccessed ?? entry.updated,
425
+ }, true, false);
426
+ }
427
+ /** @internal Shared writer: arms the expiry timer only within the safe delay range. */
428
+ setEntry(next, fromSync, persist) {
429
+ const existing = untracked(this.internal).get(next.key);
430
+ if (existing)
431
+ clearTimeout(existing.timeout); // stop the previous invalidation
432
+ const remaining = next.expiresAt - Date.now();
433
+ // already expired (clock skew on a synced/restored entry) — don't insert
434
+ if (remaining <= 0)
435
+ return;
436
+ // Infinity (immutable) or > 2^31-1 would coerce to an IMMEDIATE timeout — such
437
+ // entries get no timer and rely on lazy expiry + the periodic sweep instead
438
+ const timeout = Number.isFinite(remaining) && remaining <= MAX_TIMER_DELAY
439
+ ? setTimeout(() => this.invalidate(next.key), remaining)
440
+ : undefined;
362
441
  this.internal.mutate((map) => {
363
- map.set(key, {
364
- ...next,
365
- timeout: setTimeout(() => this.invalidate(key), ttl),
366
- });
442
+ map.set(next.key, { ...next, timeout });
367
443
  return map;
368
444
  });
369
445
  if (!fromSync) {
@@ -409,32 +485,55 @@ class Cache {
409
485
  return keys.length;
410
486
  }
411
487
  invalidateInternal(key, fromSync = false) {
412
- const entry = this.getUntracked(key);
413
- if (!entry)
414
- return;
415
- clearTimeout(entry.timeout);
416
- this.internal.mutate((map) => {
417
- map.delete(key);
418
- return map;
419
- });
488
+ // a key invalidated before async hydration completes must not be resurrected by it
489
+ if (!this.hydrated)
490
+ this.hydrationTombstones.add(key);
491
+ const entry = untracked(this.internal).get(key);
492
+ if (entry) {
493
+ clearTimeout(entry.timeout);
494
+ this.internal.mutate((map) => {
495
+ map.delete(key);
496
+ return map;
497
+ });
498
+ }
420
499
  if (!fromSync) {
421
500
  this.db.then((db) => db.remove(key));
422
501
  this.broadcast({ action: 'invalidate', entry: { key } });
423
502
  }
424
503
  }
425
- /** @internal */
504
+ /**
505
+ * Removes EVERY entry — memory, persisted rows, and (via broadcast) other tabs.
506
+ * Call on logout/auth changes so no prior user's responses survive.
507
+ */
508
+ clear() {
509
+ for (const key of Array.from(untracked(this.internal).keys())) {
510
+ this.invalidateInternal(key);
511
+ }
512
+ }
513
+ /** @internal Drops expired entries, then enforces `maxSize` by the configured strategy. */
426
514
  cleanup() {
515
+ const now = Date.now();
516
+ // expired entries first — their timers may never have fired (throttled background
517
+ // tabs, or timer-less long-TTL entries)
518
+ const expired = Array.from(untracked(this.internal).entries()).filter(([, e]) => e.expiresAt <= now);
519
+ if (expired.length) {
520
+ expired.forEach(([, e]) => clearTimeout(e.timeout));
521
+ this.internal.mutate((map) => {
522
+ expired.forEach(([key]) => map.delete(key));
523
+ return map;
524
+ });
525
+ }
427
526
  if (untracked(this.internal).size <= this.cleanupOpt.maxSize)
428
527
  return;
429
528
  const sorted = Array.from(untracked(this.internal).entries()).toSorted((a, b) => {
430
529
  if (this.cleanupOpt.type === 'lru') {
431
- return a[1].useCount - b[1].useCount; // least used first
530
+ return a[1].lastAccessed - b[1].lastAccessed; // least recently accessed first
432
531
  }
433
532
  else {
434
533
  return a[1].created - b[1].created; // oldest first
435
534
  }
436
535
  });
437
- const keepCount = Math.floor(this.cleanupOpt.maxSize / 2);
536
+ const keepCount = Math.max(1, Math.floor(this.cleanupOpt.maxSize / 2));
438
537
  const removed = sorted.slice(0, sorted.length - keepCount);
439
538
  const keep = sorted.slice(removed.length, sorted.length);
440
539
  removed.forEach(([, e]) => {
@@ -479,7 +578,8 @@ function provideQueryCache(opt) {
479
578
  return JSON.stringify({
480
579
  body: value.body,
481
580
  status: value.status,
482
- statusText: value.statusText,
581
+ // statusText intentionally omitted: deprecated in Angular, meaningless under
582
+ // HTTP/2+ (HttpResponse defaults it to 'OK' on reconstruction)
483
583
  headers: headerKeys.length > 0 ? headersRecord : undefined,
484
584
  url: value.url,
485
585
  });
@@ -495,7 +595,6 @@ function provideQueryCache(opt) {
495
595
  return new HttpResponse({
496
596
  body: parsed.body,
497
597
  status: parsed.status,
498
- statusText: parsed.statusText,
499
598
  headers: headers,
500
599
  url: parsed.url,
501
600
  });
@@ -506,48 +605,72 @@ function provideQueryCache(opt) {
506
605
  return null;
507
606
  }
508
607
  };
509
- const syncTabsOpt = opt?.syncTabs
510
- ? {
511
- id: 'mmstack-query-cache-sync',
512
- serialize,
513
- deserialize,
514
- }
515
- : undefined;
516
- const db = opt?.persist === false
517
- ? undefined
518
- : createSingleStoreDB('mmstack-query-cache-db', (version) => `query-store_v${version}`, opt?.version).then((db) => {
519
- return {
520
- getAll: () => {
521
- return db.getAll().then((entries) => {
522
- return entries
523
- .map((entry) => {
524
- const value = deserialize(entry.value);
525
- if (value === null)
526
- return null;
527
- return {
528
- ...entry,
529
- value,
530
- };
531
- })
532
- .filter((e) => e !== null);
533
- });
534
- },
535
- store: (entry) => {
536
- return db.store({ ...entry, value: serialize(entry.value) });
537
- },
538
- remove: db.remove,
539
- };
540
- });
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
+ const syncChannelId = `mmstack-query-cache-sync_v${opt?.version ?? 1}`;
541
611
  return {
542
612
  provide: CLIENT_CACHE_TOKEN,
543
- useValue: new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup, syncTabsOpt, db),
613
+ useFactory: () => {
614
+ const onServer = inject(PLATFORM_ID) === 'server';
615
+ // no IndexedDB / BroadcastChannel on the server — each request gets an
616
+ // isolated, request-lived, memory-only cache
617
+ const syncTabsOpt = !onServer && opt?.syncTabs
618
+ ? {
619
+ id: syncChannelId,
620
+ 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
+ },
544
654
  };
545
655
  }
546
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
547
667
  store(_, __, ___ = super.staleTime, ____ = super.ttl) {
548
668
  // noop
549
669
  }
550
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;
551
674
  /**
552
675
  * Injects the `QueryCache` instance that is used within queryResource.
553
676
  * Allows for direct modification of cached data, but is mostly meant for internal use.
@@ -582,10 +705,21 @@ function injectQueryCache(injector) {
582
705
  if (isDevMode())
583
706
  throw new Error('Cache not provided, please add provideQueryCache() to providers array');
584
707
  else
585
- return new NoopCache();
708
+ return (NOOP_CACHE ??= new NoopCache());
586
709
  }
587
710
  return cache;
588
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
+ }
589
723
 
590
724
  /**
591
725
  * Returns `true` for any object-like value whose own enumerable keys should
@@ -689,14 +823,121 @@ function hash(...args) {
689
823
  return hashKey(args);
690
824
  }
691
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.
870
+ */
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
+ }
692
929
  function normalizeParams(params) {
693
- const p = params instanceof HttpParams ? params : new HttpParams({ fromObject: params });
930
+ const p = params instanceof HttpParams
931
+ ? params
932
+ : new HttpParams({ fromObject: params });
694
933
  return p
695
934
  .keys()
696
935
  .toSorted()
697
936
  .map((key) => {
698
937
  const encodedKey = encodeURIComponent(key);
699
- return (p.getAll(key) ?? []).map((v) => `${encodedKey}=${encodeURIComponent(v)}`).join('&');
938
+ return (p.getAll(key) ?? [])
939
+ .map((v) => `${encodedKey}=${encodeURIComponent(v)}`)
940
+ .join('&');
700
941
  })
701
942
  .join('&');
702
943
  }
@@ -716,7 +957,8 @@ function hashBody(body) {
716
957
  entries.sort(([ak, av], [bk, bv]) => ak.localeCompare(bk) || av.localeCompare(bv));
717
958
  return `FormData:${entries.map(([k, v]) => `${k}=${v}`).join('&')}`;
718
959
  }
719
- if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
960
+ if (typeof URLSearchParams !== 'undefined' &&
961
+ body instanceof URLSearchParams) {
720
962
  const sp = new URLSearchParams(body);
721
963
  sp.sort();
722
964
  return `URLSearchParams:${sp.toString()}`;
@@ -733,20 +975,48 @@ function hashBody(body) {
733
975
  * Builds a stable cache/dedupe key from an HTTP request shape (accepts both
734
976
  * `HttpRequest` and `HttpResourceRequest`).
735
977
  *
736
- * Key composition: `${method}:${url}:${responseType}[:${params}][:${body}]`
978
+ * Key composition: `${method}:${url}:${responseType}[:${params}][:${body}][:${vary}]`
737
979
  * - `method` defaults to `'GET'`, `responseType` to `'json'` (Angular defaults).
738
980
  * - Query params are sorted alphabetically and URL-encoded for stability.
739
981
  * - Body hashing handles `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`
740
982
  * and typed arrays explicitly; everything else flows through key-sorted
741
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.
742
990
  */
743
- function hashRequest(req) {
991
+ function hashRequest(req, varyHeaders) {
744
992
  const method = req.method ?? 'GET';
745
993
  const responseType = req.responseType ?? 'json';
746
994
  const base = `${method}:${req.url}:${responseType}`;
747
995
  const params = req.params ? `:${normalizeParams(req.params)}` : '';
748
996
  const body = req.body != null ? `:${hashBody(req.body)}` : '';
749
- return base + params + body;
997
+ const vary = varyHeaders?.length
998
+ ? `:vary(${normalizeVaryHeaders(req.headers, varyHeaders)})`
999
+ : '';
1000
+ return base + params + body + vary;
1001
+ }
1002
+
1003
+ /**
1004
+ * @internal
1005
+ * Single-flight sharing: if a pending observable is already registered under `key`,
1006
+ * return it; otherwise create one, share it (replaying the latest event to late
1007
+ * subscribers), and deregister it on teardown/settle.
1008
+ *
1009
+ * Used by both the dedupe interceptor (keyed by full request hash, app-wide) and the
1010
+ * cache interceptor (keyed by the CACHE key, guarding the miss/stale-revalidation path)
1011
+ * — same mechanism, different keying/scope, so it lives here exactly once.
1012
+ */
1013
+ function sharePending(pending, key, create) {
1014
+ const existing = pending.get(key);
1015
+ if (existing)
1016
+ return existing;
1017
+ const shared = create().pipe(finalize(() => pending.delete(key)), shareReplay({ bufferSize: 1, refCount: true }));
1018
+ pending.set(key, shared);
1019
+ return shared;
750
1020
  }
751
1021
 
752
1022
  const CACHE_CONTEXT = new HttpContextToken(() => ({
@@ -766,6 +1036,7 @@ function parseCacheControlHeader(req) {
766
1036
  noCache: false,
767
1037
  mustRevalidate: false,
768
1038
  immutable: false,
1039
+ isPrivate: false,
769
1040
  maxAge: null,
770
1041
  staleWhileRevalidate: null,
771
1042
  };
@@ -789,6 +1060,9 @@ function parseCacheControlHeader(req) {
789
1060
  case 'immutable':
790
1061
  directives.immutable = true;
791
1062
  break;
1063
+ case 'private':
1064
+ directives.isPrivate = true;
1065
+ break;
792
1066
  case 'max-age': {
793
1067
  if (!value)
794
1068
  break;
@@ -797,7 +1071,7 @@ function parseCacheControlHeader(req) {
797
1071
  directives.maxAge = parsedValue;
798
1072
  break;
799
1073
  }
800
- case 's-max-age': {
1074
+ case 's-maxage': {
801
1075
  if (!value)
802
1076
  break;
803
1077
  const parsedValue = parseInt(value, 10);
@@ -815,7 +1089,7 @@ function parseCacheControlHeader(req) {
815
1089
  }
816
1090
  }
817
1091
  }
818
- // s-max-age takes precedence over max-age
1092
+ // s-maxage takes precedence over max-age
819
1093
  if (sMaxAge !== null)
820
1094
  directives.maxAge = sMaxAge;
821
1095
  // if no store nothing else is relevant
@@ -825,6 +1099,7 @@ function parseCacheControlHeader(req) {
825
1099
  noCache: false,
826
1100
  mustRevalidate: false,
827
1101
  immutable: false,
1102
+ isPrivate: directives.isPrivate,
828
1103
  maxAge: null,
829
1104
  staleWhileRevalidate: null,
830
1105
  };
@@ -844,14 +1119,32 @@ function resolveTimings(cacheControl, optStaleTime, optTTL) {
844
1119
  staleTime: Infinity,
845
1120
  ttl: Infinity,
846
1121
  };
847
- if (cacheControl.maxAge !== null)
848
- ttl = cacheControl.maxAge * 1000;
849
- if (cacheControl.staleWhileRevalidate !== null)
850
- staleTime = cacheControl.staleWhileRevalidate * 1000;
851
- // if no-cache is set, we must always revalidate
1122
+ if (cacheControl.maxAge !== null) {
1123
+ staleTime = cacheControl.maxAge * 1000;
1124
+ if (cacheControl.staleWhileRevalidate !== null) {
1125
+ ttl = staleTime + cacheControl.staleWhileRevalidate * 1000;
1126
+ }
1127
+ else if (ttl !== undefined) {
1128
+ // a configured total lifetime must never undercut the server's fresh window
1129
+ ttl = Math.max(ttl, staleTime);
1130
+ }
1131
+ // no swr + no configured ttl → leave undefined so the cache's default ttl applies
1132
+ // (the entry stays resident past max-age for ETag revalidation)
1133
+ }
1134
+ else if (cacheControl.staleWhileRevalidate !== null) {
1135
+ // swr without max-age: stale immediately, revalidatable for the window
1136
+ staleTime = 0;
1137
+ ttl = cacheControl.staleWhileRevalidate * 1000;
1138
+ }
1139
+ // if no-cache is set, we must always revalidate (the entry stays usable for conditional requests until ttl)
852
1140
  if (cacheControl.noCache || cacheControl.mustRevalidate)
853
1141
  staleTime = 0;
854
- if (ttl !== undefined && staleTime !== undefined && ttl < staleTime) {
1142
+ // option-only path (no server freshness): a misconfigured ttl < staleTime clamps the
1143
+ // fresh window down, mirroring the cache's own internal clamp
1144
+ if (cacheControl.maxAge === null &&
1145
+ ttl !== undefined &&
1146
+ staleTime !== undefined &&
1147
+ ttl < staleTime) {
855
1148
  staleTime = ttl;
856
1149
  }
857
1150
  return { staleTime, ttl };
@@ -865,6 +1158,10 @@ function resolveTimings(cacheControl, optStaleTime, optTTL) {
865
1158
  * is made to the server, and the response is cached according to the configured TTL and staleness.
866
1159
  * The interceptor also respects `Cache-Control` headers from the server.
867
1160
  *
1161
+ * Cache-enabled requests are single-flighted per cache key: N concurrent consumers of
1162
+ * the same missing/stale entry share ONE network request. Non-cached requests are not
1163
+ * touched — pair with `createDedupeRequestsInterceptor` to coalesce those as well.
1164
+ *
868
1165
  * @param allowedMethods - An array of HTTP methods for which caching should be enabled.
869
1166
  * Defaults to `['GET', 'HEAD', 'OPTIONS']`.
870
1167
  *
@@ -885,7 +1182,10 @@ function resolveTimings(cacheControl, optStaleTime, optTTL) {
885
1182
  */
886
1183
  function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
887
1184
  const CACHE_METHODS = new Set(allowedMethods);
1185
+ const inFlight = new Map();
888
1186
  return (req, next) => {
1187
+ if (inject(PLATFORM_ID) === 'server')
1188
+ return next(req);
889
1189
  const cache = injectQueryCache();
890
1190
  if (!CACHE_METHODS.has(req.method))
891
1191
  return next(req);
@@ -898,60 +1198,78 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
898
1198
  if (entry && !entry.isStale)
899
1199
  return of(entry.value);
900
1200
  // resource itself handles case of showing stale data...the request must process as this will "refresh said data"
901
- const eTag = entry?.value.headers.get('ETag');
902
- const lastModified = entry?.value.headers.get('Last-Modified');
903
- if (eTag) {
904
- req = req.clone({ setHeaders: { 'If-None-Match': eTag } });
905
- }
906
- if (lastModified) {
907
- req = req.clone({ setHeaders: { 'If-Modified-Since': lastModified } });
908
- }
909
- if (opt.bustBrowserCache) {
910
- req = req.clone({
911
- setParams: { _cb: Date.now().toString() },
912
- });
913
- }
914
- return next(req).pipe(tap((event) => {
915
- if (!(event instanceof HttpResponse))
916
- return;
917
- if (event.ok) {
918
- const cacheControl = parseCacheControlHeader(event);
919
- if (cacheControl.noStore && !opt.ignoreCacheControl)
920
- return;
921
- const { staleTime, ttl } = opt.ignoreCacheControl
922
- ? opt
923
- : resolveTimings(cacheControl, opt.staleTime, opt.ttl);
924
- if (opt.ttl === 0)
925
- return; // no point
926
- const parsedResponse = opt.parse
927
- ? new HttpResponse({
928
- body: opt.parse(event.body),
929
- headers: event.headers,
930
- status: event.status,
931
- statusText: event.statusText,
932
- url: event.url ?? undefined,
933
- })
934
- : event;
935
- cache.store(key, parsedResponse, staleTime, ttl, opt.persist);
936
- return;
1201
+ return sharePending(inFlight, key, () => {
1202
+ const eTag = entry?.value.headers.get('ETag');
1203
+ const lastModified = entry?.value.headers.get('Last-Modified');
1204
+ if (eTag) {
1205
+ req = req.clone({ setHeaders: { 'If-None-Match': eTag } });
1206
+ }
1207
+ if (lastModified) {
1208
+ req = req.clone({ setHeaders: { 'If-Modified-Since': lastModified } });
937
1209
  }
938
- // 304 → server confirmed our cached entry is still valid. Re-stamp the
939
- // existing entry so subsequent reads within the new freshness window
940
- // don't trigger another revalidation round-trip.
941
- if (event.status === 304 && entry) {
942
- const cacheControl = parseCacheControlHeader(event);
943
- const { staleTime, ttl } = opt.ignoreCacheControl
944
- ? opt
945
- : resolveTimings(cacheControl, opt.staleTime, opt.ttl);
946
- cache.store(key, entry.value, staleTime, ttl, opt.persist);
1210
+ if (opt.bustBrowserCache) {
1211
+ req = req.clone({
1212
+ setParams: { _cb: Date.now().toString() },
1213
+ });
947
1214
  }
948
- }), map((event) => {
949
- // handle 304 responses due to eTag/last-modified
950
- if (event instanceof HttpResponse && event.status === 304 && entry) {
951
- return entry.value;
1215
+ // non-JSON bodies (blob/arraybuffer) cannot survive the JSON persistence layer
1216
+ const persistable = req.responseType === 'json';
1217
+ if (opt.persist && !persistable && isDevMode()) {
1218
+ console.warn(`[@mmstack/resource]: persist was requested for a '${req.responseType}' response — such bodies don't survive JSON serialization, persisting skipped.`);
952
1219
  }
953
- return event;
954
- }));
1220
+ return next(req).pipe(tap((event) => {
1221
+ if (!(event instanceof HttpResponse))
1222
+ return;
1223
+ if (event.ok) {
1224
+ const cacheControl = parseCacheControlHeader(event);
1225
+ if (cacheControl.noStore && !opt.ignoreCacheControl)
1226
+ return;
1227
+ const { staleTime, ttl } = opt.ignoreCacheControl
1228
+ ? opt
1229
+ : resolveTimings(cacheControl, opt.staleTime, opt.ttl);
1230
+ if (ttl === 0)
1231
+ return; // no point
1232
+ // `Cache-Control: private` → fine to keep in memory, never on disk
1233
+ const persist = (opt.persist ?? false) &&
1234
+ persistable &&
1235
+ (opt.ignoreCacheControl || !cacheControl.isPrivate);
1236
+ const parsedResponse = opt.parse
1237
+ ? // statusText omitted — deprecated in Angular (HttpResponse defaults it)
1238
+ new HttpResponse({
1239
+ body: opt.parse(event.body),
1240
+ headers: event.headers,
1241
+ status: event.status,
1242
+ url: event.url ?? undefined,
1243
+ })
1244
+ : event;
1245
+ cache.store(key, parsedResponse, staleTime, ttl, persist);
1246
+ return;
1247
+ }
1248
+ // 304 → server confirmed our cached entry is still valid. Re-stamp the
1249
+ // existing entry so subsequent reads within the new freshness window
1250
+ // don't trigger another revalidation round-trip.
1251
+ if (event.status === 304 && entry) {
1252
+ // ...unless the key was invalidated while this conditional request was in
1253
+ // flight (e.g. by a mutation) — re-storing would resurrect deleted data
1254
+ if (!cache.getUntracked(key))
1255
+ return;
1256
+ const cacheControl = parseCacheControlHeader(event);
1257
+ const { staleTime, ttl } = opt.ignoreCacheControl
1258
+ ? opt
1259
+ : resolveTimings(cacheControl, opt.staleTime, opt.ttl);
1260
+ const persist = (opt.persist ?? false) &&
1261
+ persistable &&
1262
+ (opt.ignoreCacheControl || !cacheControl.isPrivate);
1263
+ cache.store(key, entry.value, staleTime, ttl, persist);
1264
+ }
1265
+ }), map((event) => {
1266
+ // handle 304 responses due to eTag/last-modified
1267
+ if (event instanceof HttpResponse && event.status === 304 && entry) {
1268
+ return entry.value;
1269
+ }
1270
+ return event;
1271
+ }));
1272
+ });
955
1273
  };
956
1274
  }
957
1275
 
@@ -1173,6 +1491,12 @@ function noDedupe(ctx = new HttpContext()) {
1173
1491
  * only the first request will be sent to the server. Subsequent requests will
1174
1492
  * receive the response from the first request.
1175
1493
  *
1494
+ * Relationship to `createCacheInterceptor`: the cache interceptor has built-in
1495
+ * single-flight for CACHE-ENABLED requests (keyed by the cache key). This interceptor
1496
+ * covers everything the cache doesn't see — non-cached resources, plain HttpClient
1497
+ * calls, DELETEs — keyed by the request hash. Installing both is the recommended
1498
+ * setup; where they overlap, this one degrades to a no-op passthrough.
1499
+ *
1176
1500
  * @param allowed - An array of HTTP methods for which deduplication should be enabled.
1177
1501
  * Defaults to `['GET', 'DELETE', 'HEAD', 'OPTIONS']`.
1178
1502
  * @param keyFn - Optional function to compute the dedupe key from a request.
@@ -1207,13 +1531,7 @@ function createDedupeRequestsInterceptor(allowed = ['GET', 'DELETE', 'HEAD', 'OP
1207
1531
  return (req, next) => {
1208
1532
  if (!DEDUPE_METHODS.has(req.method) || req.context.get(NO_DEDUPE))
1209
1533
  return next(req);
1210
- const key = keyFn(req);
1211
- const found = inFlight.get(key);
1212
- if (found)
1213
- return found;
1214
- const request = next(req).pipe(finalize(() => inFlight.delete(key)), shareReplay({ bufferSize: 1, refCount: true }));
1215
- inFlight.set(key, request);
1216
- return request;
1534
+ return sharePending(inFlight, keyFn(req), () => next(req));
1217
1535
  };
1218
1536
  }
1219
1537
 
@@ -1386,6 +1704,57 @@ function hasSlowConnection() {
1386
1704
  return false;
1387
1705
  }
1388
1706
 
1707
+ /**
1708
+ * Deep merges multiple circuit breaker options.
1709
+ * The latter options override the former.
1710
+ */
1711
+ function mergeCircuitBreakerOptions(global, query, local) {
1712
+ if (!global && !query && !local)
1713
+ return undefined;
1714
+ return {
1715
+ ...(global === true ? {} : global),
1716
+ ...(query === true ? {} : query),
1717
+ ...(local === true ? {} : local),
1718
+ };
1719
+ }
1720
+ /**
1721
+ * Deep merges multiple retry options.
1722
+ * The latter options override the former.
1723
+ */
1724
+ function mergeRetryOptions(global, query, local) {
1725
+ if (global === undefined && query === undefined && local === undefined)
1726
+ return undefined;
1727
+ return {
1728
+ ...(typeof global === 'number' ? { max: global } : global),
1729
+ ...(typeof query === 'number' ? { max: query } : query),
1730
+ ...(typeof local === 'number' ? { max: local } : local),
1731
+ };
1732
+ }
1733
+ /**
1734
+ * Deep merges multiple cache options.
1735
+ * The latter options override the former.
1736
+ */
1737
+ function mergeCacheOptions(query, local) {
1738
+ if (query === undefined && local === undefined)
1739
+ return undefined;
1740
+ return {
1741
+ ...(query === true ? {} : query),
1742
+ ...(local === true ? {} : local),
1743
+ };
1744
+ }
1745
+ /**
1746
+ * Deep merges multiple refresh options.
1747
+ * The latter options override the former.
1748
+ */
1749
+ function mergeRefreshOptions(query, local) {
1750
+ if (query === undefined && local === undefined)
1751
+ return undefined;
1752
+ return {
1753
+ ...(typeof query === 'number' ? { interval: query } : query),
1754
+ ...(typeof local === 'number' ? { interval: local } : local),
1755
+ };
1756
+ }
1757
+
1389
1758
  function persistResourceValues(resource, shouldPersist = false, equal) {
1390
1759
  if (!shouldPersist)
1391
1760
  return resource;
@@ -1397,24 +1766,61 @@ function persistResourceValues(resource, shouldPersist = false, equal) {
1397
1766
  };
1398
1767
  }
1399
1768
 
1400
- // refresh resource every n miliseconds or don't refresh if undefined provided. 0 also excluded, due to it not being a valid usecase.
1401
- function refresh(resource, destroyRef, refresh, inactive) {
1402
- if (!refresh)
1769
+ // refresh resource every n milliseconds and/or on visibility/reconnect transitions.
1770
+ function refresh(resource, destroyRef, opt, inactive, triggers) {
1771
+ const normalized = typeof opt === 'number' ? { interval: opt } : (opt ?? {});
1772
+ const { interval: ms, onFocus = false, onReconnect = false, } = normalized;
1773
+ const hasInterval = !!ms; // 0 excluded — not a valid polling cadence
1774
+ const hasTriggerEffects = !!triggers && (onFocus || onReconnect);
1775
+ if (!hasInterval && !hasTriggerEffects)
1403
1776
  return resource; // no refresh requested
1404
1777
  const tick = () => {
1405
1778
  if (inactive?.())
1406
- return; // disabled / paused → skip the poll
1779
+ return; // disabled / paused → skip
1407
1780
  resource.reload();
1408
1781
  };
1782
+ const effectRefs = [];
1783
+ if (triggers && onFocus) {
1784
+ const vis = triggers.visibility;
1785
+ let prev = untracked(vis);
1786
+ effectRefs.push(effect(() => {
1787
+ const next = vis();
1788
+ const was = prev;
1789
+ prev = next;
1790
+ // only the hidden → visible TRANSITION refreshes — not the initial run
1791
+ if (was !== 'visible' && next === 'visible')
1792
+ untracked(tick);
1793
+ }, { injector: triggers.injector }));
1794
+ }
1795
+ if (triggers && onReconnect) {
1796
+ const online = triggers.online;
1797
+ let prev = untracked(online);
1798
+ effectRefs.push(effect(() => {
1799
+ const next = online();
1800
+ const was = prev;
1801
+ prev = next;
1802
+ if (!was && next)
1803
+ untracked(tick);
1804
+ }, { injector: triggers.injector }));
1805
+ }
1806
+ if (!hasInterval) {
1807
+ return {
1808
+ ...resource,
1809
+ destroy: () => {
1810
+ effectRefs.forEach((ref) => ref.destroy());
1811
+ resource.destroy();
1812
+ },
1813
+ };
1814
+ }
1409
1815
  // we can use RxJs here as reloading the resource will always be a side effect & as such does not impact the reactive graph in any way.
1410
- let sub = interval(refresh)
1816
+ let sub = interval(ms)
1411
1817
  .pipe(takeUntilDestroyed(destroyRef))
1412
1818
  .subscribe(tick);
1413
1819
  const reload = () => {
1414
1820
  sub.unsubscribe(); // do not conflict with manual reload
1415
1821
  const hasReloaded = resource.reload();
1416
1822
  // resubscribe after manual reload
1417
- sub = interval(refresh)
1823
+ sub = interval(ms)
1418
1824
  .pipe(takeUntilDestroyed(destroyRef))
1419
1825
  .subscribe(tick);
1420
1826
  return hasReloaded;
@@ -1424,6 +1830,7 @@ function refresh(resource, destroyRef, refresh, inactive) {
1424
1830
  reload,
1425
1831
  destroy: () => {
1426
1832
  sub.unsubscribe();
1833
+ effectRefs.forEach((ref) => ref.destroy());
1427
1834
  resource.destroy();
1428
1835
  },
1429
1836
  };
@@ -1470,6 +1877,7 @@ function retryOnError(res, opt, onError) {
1470
1877
 
1471
1878
  class ResourceSensors {
1472
1879
  networkStatus = sensor('networkStatus');
1880
+ pageVisibility = sensor('pageVisibility');
1473
1881
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: ResourceSensors, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1474
1882
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: ResourceSensors, providedIn: 'root' });
1475
1883
  }
@@ -1482,6 +1890,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
1482
1890
  function injectNetworkStatus() {
1483
1891
  return inject(ResourceSensors).networkStatus;
1484
1892
  }
1893
+ function injectPageVisibility() {
1894
+ return inject(ResourceSensors).pageVisibility;
1895
+ }
1485
1896
 
1486
1897
  function toResourceObject(res) {
1487
1898
  return {
@@ -1525,10 +1936,16 @@ function injectQueryResourceOptions(injector) {
1525
1936
  const PAUSED = Symbol('@mmstack/resource:paused');
1526
1937
  function queryResource(request, options0) {
1527
1938
  // Two-layer option injection: per-call > provideQueryResourceOptions > provideResourceOptions.
1939
+ const globalOpts = injectResourceOptions(options0?.injector);
1940
+ const queryOpts = injectQueryResourceOptions(options0?.injector);
1528
1941
  const options = {
1529
- ...injectResourceOptions(options0?.injector),
1530
- ...injectQueryResourceOptions(options0?.injector),
1942
+ ...globalOpts,
1943
+ ...queryOpts,
1531
1944
  ...options0,
1945
+ cache: mergeCacheOptions(queryOpts.cache, options0?.cache),
1946
+ circuitBreaker: mergeCircuitBreakerOptions(globalOpts.circuitBreaker, queryOpts.circuitBreaker, options0?.circuitBreaker),
1947
+ retry: mergeRetryOptions(globalOpts.retry, queryOpts.retry, options0?.retry),
1948
+ refresh: mergeRefreshOptions(queryOpts.refresh, options0?.refresh),
1532
1949
  };
1533
1950
  const cache = injectQueryCache(options?.injector);
1534
1951
  const destroyRef = options?.injector
@@ -1541,9 +1958,19 @@ function queryResource(request, options0) {
1541
1958
  const eq = options?.triggerOnSameRequest
1542
1959
  ? undefined
1543
1960
  : (options?.equalRequest ?? createEqualRequest());
1961
+ // Opt-in auto-pausing: `true` reads the ambient Activity boundary (no-op outside
1962
+ // one), a predicate is used directly. Composes with the manual `ctx.paused` path.
1963
+ const pauseOpt = options?.pause ?? false;
1964
+ const externallyPaused = pauseOpt === false
1965
+ ? () => false
1966
+ : typeof pauseOpt === 'function'
1967
+ ? pauseOpt
1968
+ : options?.injector
1969
+ ? runInInjectionContext(options.injector, injectPaused)
1970
+ : injectPaused();
1544
1971
  const requestCtx = { paused: PAUSED };
1545
1972
  const rawResult = computed(() => request(requestCtx), ...(ngDevMode ? [{ debugName: "rawResult" }] : []));
1546
- const paused = computed(() => rawResult() === PAUSED, ...(ngDevMode ? [{ debugName: "paused" }] : []));
1973
+ const paused = computed(() => rawResult() === PAUSED || externallyPaused(), ...(ngDevMode ? [{ debugName: "paused" }] : []));
1547
1974
  const rawRequest = computed(() => {
1548
1975
  const r = rawResult();
1549
1976
  return r === PAUSED ? undefined : (r ?? undefined);
@@ -1553,9 +1980,12 @@ function queryResource(request, options0) {
1553
1980
  return 'offline';
1554
1981
  if (cb.isOpen())
1555
1982
  return 'circuit-open';
1556
- // PAUSED makes rawRequest undefined, so it reports 'no-request' here (and skips polling),
1557
- // while stableRequest below HOLDS the last request so the value is kept (no refetch on resume).
1558
- if (!rawRequest())
1983
+ // Both pause sources report 'no-request' here ctx.paused makes rawRequest
1984
+ // undefined, while the external `pause` option still yields a real request, so it
1985
+ // must be checked explicitly. Either way this also stops polling/refresh triggers
1986
+ // (their inactive() guard reads disabledReason), while stableRequest below HOLDS
1987
+ // the last request so the value is kept (no refetch on resume).
1988
+ if (paused() || !rawRequest())
1559
1989
  return 'no-request';
1560
1990
  return null;
1561
1991
  }, ...(ngDevMode ? [{ debugName: "disabledReason" }] : []));
@@ -1609,8 +2039,10 @@ function queryResource(request, options0) {
1609
2039
  return a === b;
1610
2040
  },
1611
2041
  }]));
2042
+ const varyHeaders = typeof options?.cache === 'object' ? options.cache.varyHeaders : undefined;
1612
2043
  const hashFn = typeof options?.cache === 'object'
1613
- ? (options.cache.hash ?? hashRequest)
2044
+ ? (options.cache.hash ??
2045
+ ((r) => hashRequest(r, varyHeaders)))
1614
2046
  : hashRequest;
1615
2047
  const staleTime = typeof options?.cache === 'object' ? options.cache.staleTime : 0;
1616
2048
  const ttl = typeof options?.cache === 'object' ? options.cache.ttl : undefined;
@@ -1689,22 +2121,30 @@ function queryResource(request, options0) {
1689
2121
  };
1690
2122
  },
1691
2123
  }]));
1692
- // A disabled (offline / circuit-open / no-request) or PAUSED resource must not poll.
1693
- resource = refresh(resource, destroyRef, options?.refresh, () => disabledReason() !== null);
2124
+ // A disabled (offline / circuit-open / no-request) or PAUSED resource must not poll
2125
+ // or react to focus/reconnect.
2126
+ resource = refresh(resource, destroyRef, options?.refresh, () => disabledReason() !== null, {
2127
+ injector: options?.injector ?? inject(Injector),
2128
+ visibility: injectPageVisibility(),
2129
+ online: networkAvailable,
2130
+ });
1694
2131
  resource = retryOnError(resource, options?.retry, options?.onError);
1695
2132
  resource = persistResourceValues(resource, options?.keepPrevious, options?.equal);
1696
2133
  const set = (value) => {
1697
2134
  resource.value.set(value);
1698
2135
  const k = untracked(cacheKey);
1699
2136
  if (options?.cache && k)
1700
- cache.store(k, new HttpResponse({
2137
+ cache.store(k,
2138
+ // statusText omitted — deprecated in Angular (HttpResponse defaults it)
2139
+ new HttpResponse({
1701
2140
  body: value,
1702
2141
  status: 200,
1703
- statusText: 'OK',
1704
2142
  }), staleTime, ttl, persist);
1705
2143
  };
1706
2144
  const update = (updater) => {
1707
- set(updater(untracked(resource.value)));
2145
+ // baseline on the COMPOSED value (cache-preferring): the cache entry can be newer
2146
+ // than resource.value (cross-tab sync, another instance's set)
2147
+ set(updater(untracked(value)));
1708
2148
  };
1709
2149
  const value = options?.cache
1710
2150
  ? toWritable(computed(() => cacheEntry()?.value ?? resource.value()), set, update)
@@ -1792,6 +2232,101 @@ function queryResource(request, options0) {
1792
2232
  return ref;
1793
2233
  }
1794
2234
 
2235
+ /**
2236
+ * Creates a paginated HTTP resource over {@link queryResource}: one page request at a
2237
+ * time, accumulated into a `pages` signal — cursor- and offset-based pagination both
2238
+ * fit through `getNextPageParam`. Each page request inherits the full queryResource
2239
+ * feature set (caching per page, retries, circuit breaker, refresh triggers).
2240
+ *
2241
+ * @example
2242
+ * ```ts
2243
+ * const posts = infiniteQueryResource<PostPage, PostPage, number>(
2244
+ * ({ pageParam }) => ({ url: '/api/posts', params: { page: pageParam } }),
2245
+ * {
2246
+ * initialPageParam: 0,
2247
+ * getNextPageParam: (last, all) => (last.items.length < 20 ? null : all.length),
2248
+ * cache: true,
2249
+ * },
2250
+ * );
2251
+ *
2252
+ * // template:
2253
+ * // @for (page of posts.pages(); track $index) { ... }
2254
+ * // <button (click)="posts.fetchNextPage()" [disabled]="!posts.hasNextPage()">More</button>
2255
+ * const flat = computed(() => posts.pages().flatMap((p) => p.items));
2256
+ * ```
2257
+ */
2258
+ function infiniteQueryResource(request, options) {
2259
+ const { initialPageParam, getNextPageParam, ...rest } = options;
2260
+ const injector = options.injector ?? inject(Injector);
2261
+ const pageParam = signal(initialPageParam, ...(ngDevMode ? [{ debugName: "pageParam" }] : []));
2262
+ // pages keyed by the param that produced them, so a reload of an already-loaded
2263
+ // page REPLACES its slot instead of appending a duplicate
2264
+ const loaded = signal([], ...(ngDevMode ? [{ debugName: "loaded" }] : []));
2265
+ const resource = queryResource(
2266
+ // forward queryResource's own context so the fn can return ctx.paused —
2267
+ // pausing holds the loaded pages and stops page fetches until unpaused
2268
+ (qctx) => request({ ...qctx, pageParam: pageParam() }), { ...rest, injector });
2269
+ const appendRef = effect(() => {
2270
+ if (resource.status() !== 'resolved')
2271
+ return;
2272
+ const page = resource.value();
2273
+ if (page === undefined)
2274
+ return;
2275
+ untracked(() => {
2276
+ const param = pageParam();
2277
+ loaded.update((list) => {
2278
+ const idx = list.findIndex((e) => Object.is(e.param, param));
2279
+ if (idx >= 0) {
2280
+ const copy = [...list];
2281
+ copy[idx] = { param, page };
2282
+ return copy;
2283
+ }
2284
+ return [...list, { param, page }];
2285
+ });
2286
+ });
2287
+ }, ...(ngDevMode ? [{ debugName: "appendRef", injector }] : [{ injector }]));
2288
+ const pages = computed(() => loaded().map((e) => e.page), ...(ngDevMode ? [{ debugName: "pages" }] : []));
2289
+ const nextPageParam = computed(() => {
2290
+ const all = pages();
2291
+ if (all.length === 0)
2292
+ return null;
2293
+ return getNextPageParam(all[all.length - 1], all) ?? null;
2294
+ }, ...(ngDevMode ? [{ debugName: "nextPageParam" }] : []));
2295
+ const hasNextPage = computed(() => nextPageParam() !== null, ...(ngDevMode ? [{ debugName: "hasNextPage" }] : []));
2296
+ const fetchNextPage = () => {
2297
+ if (untracked(resource.isLoading))
2298
+ return; // one page at a time
2299
+ const next = untracked(nextPageParam);
2300
+ if (next === null)
2301
+ return;
2302
+ pageParam.set(next);
2303
+ };
2304
+ const reset = () => {
2305
+ loaded.set([]);
2306
+ if (Object.is(untracked(pageParam), initialPageParam)) {
2307
+ resource.reload(); // param unchanged — force the refetch
2308
+ }
2309
+ else {
2310
+ pageParam.set(initialPageParam);
2311
+ }
2312
+ };
2313
+ return {
2314
+ pages,
2315
+ hasNextPage,
2316
+ isFetchingNextPage: computed(() => resource.isLoading() && loaded().length > 0),
2317
+ isLoading: resource.isLoading,
2318
+ status: resource.status,
2319
+ error: resource.error,
2320
+ fetchNextPage,
2321
+ reload: () => resource.reload(),
2322
+ reset,
2323
+ destroy: () => {
2324
+ appendRef.destroy();
2325
+ resource.destroy();
2326
+ },
2327
+ };
2328
+ }
2329
+
1795
2330
  function manualQueryResource(request, options) {
1796
2331
  const trigger = signal({ epoch: 0 }, ...(ngDevMode ? [{ debugName: "trigger", equal: (a, b) => a.epoch === b.epoch }] : [{
1797
2332
  equal: (a, b) => a.epoch === b.epoch,
@@ -1808,6 +2343,12 @@ function manualQueryResource(request, options) {
1808
2343
  equal: () => false,
1809
2344
  }]));
1810
2345
  const resource = queryResource(req, options);
2346
+ // Shared across trigger() calls: a per-call watcher could observe the PREVIOUS
2347
+ // request's `resolved` status before this trigger's load flips the resource to
2348
+ // loading (effect ordering within a flush is unspecified) and resolve with stale
2349
+ // data; concurrent triggers would also cross-resolve each other's promises.
2350
+ let pending = [];
2351
+ let watcher = null;
1811
2352
  return {
1812
2353
  ...resource,
1813
2354
  trigger: (override, injectorOverride) => {
@@ -1816,15 +2357,41 @@ function manualQueryResource(request, options) {
1816
2357
  override,
1817
2358
  }));
1818
2359
  return new Promise((res, rej) => {
1819
- const watcher = nestedEffect(() => {
2360
+ if (untracked(req) === undefined) {
2361
+ // the request fn produced nothing — no load will ever start, so a watcher
2362
+ // would hang this promise forever
2363
+ rej(new Error('[@mmstack/resource]: trigger() produced no request (the request fn returned undefined)'));
2364
+ return;
2365
+ }
2366
+ pending.push({ res, rej });
2367
+ // an active watcher (concurrent trigger) settles ALL pending promises with
2368
+ // the final result of the latest request — TanStack-style latest-wins
2369
+ if (watcher)
2370
+ return;
2371
+ // only accept a settle AFTER the load for this trigger has been observed —
2372
+ // the pre-trigger status may still be a stale `resolved`/`error`
2373
+ let sawLoading = false;
2374
+ watcher = nestedEffect(() => {
1820
2375
  const status = resource.status();
1821
- if (status === 'resolved') {
1822
- watcher.destroy();
1823
- res(untracked(resource.value));
2376
+ if (status === 'loading' || status === 'reloading') {
2377
+ sawLoading = true;
2378
+ return;
1824
2379
  }
1825
- else if (status === 'error') {
1826
- watcher.destroy();
1827
- rej(untracked(resource.error));
2380
+ if (!sawLoading)
2381
+ return;
2382
+ if (status === 'resolved' || status === 'error') {
2383
+ const settled = pending;
2384
+ pending = [];
2385
+ watcher?.destroy();
2386
+ watcher = null;
2387
+ if (status === 'resolved') {
2388
+ const value = untracked(resource.value);
2389
+ settled.forEach((p) => p.res(value));
2390
+ }
2391
+ else {
2392
+ const err = untracked(resource.error);
2393
+ settled.forEach((p) => p.rej(err));
2394
+ }
1828
2395
  }
1829
2396
  }, { injector: injectorOverride ?? injector });
1830
2397
  });
@@ -1897,14 +2464,19 @@ function injectMutationResourceOptions(injector) {
1897
2464
  */
1898
2465
  function mutationResource(request, options0 = {}) {
1899
2466
  // Two-layer option injection: per-call > provideMutationResourceOptions > provideResourceOptions.
2467
+ const globalOpts = injectResourceOptions(options0.injector);
2468
+ const mutOpts = injectMutationResourceOptions(options0.injector);
1900
2469
  const options = {
1901
- ...injectResourceOptions(options0.injector),
1902
- ...injectMutationResourceOptions(options0.injector),
2470
+ ...globalOpts,
2471
+ ...mutOpts,
1903
2472
  ...options0,
2473
+ circuitBreaker: mergeCircuitBreakerOptions(globalOpts.circuitBreaker, mutOpts.circuitBreaker, options0?.circuitBreaker),
2474
+ retry: mergeRetryOptions(globalOpts.retry, mutOpts.retry, options0?.retry),
1904
2475
  };
1905
2476
  // `register` is pulled out (and forced off on the inner query below) so the mutation ref is
1906
2477
  // the only thing registered into the transition scope, not its internal query resource.
1907
- const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, ...rest } = options;
2478
+ const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, invalidates, ...rest } = options;
2479
+ const cache = invalidates ? injectQueryCache(options.injector) : undefined;
1908
2480
  const requestEqual = equalRequest ?? createEqualRequest(equal);
1909
2481
  // A mutation is an imperative command, so `triggerOnSameRequest` means "fire on EVERY mutate(),
1910
2482
  // even with an identical body". By default we dedup an identical value/request while one is in
@@ -2042,8 +2614,19 @@ function mutationResource(request, options0 = {}) {
2042
2614
  .subscribe((result) => {
2043
2615
  if (result.status === 'error')
2044
2616
  onError?.(result.error, ctx);
2045
- else
2617
+ else {
2046
2618
  onSuccess?.(result.value, ctx);
2619
+ if (cache && invalidates) {
2620
+ const mutation = untracked(lastValue);
2621
+ const prefixes = typeof invalidates === 'function'
2622
+ ? invalidates(result.value, (mutation === NULL_VALUE ? undefined : mutation))
2623
+ : invalidates;
2624
+ // auto-keys are `${method}:${url}:...` — a `GET:`-prefixed url prefix hits
2625
+ // the url with any params/subpaths and every varyHeaders variant
2626
+ for (const prefix of prefixes)
2627
+ cache.invalidatePrefix(`GET:${prefix}`);
2628
+ }
2629
+ }
2047
2630
  onSettled?.(ctx);
2048
2631
  ctx = undefined;
2049
2632
  next.set(NULL_VALUE);
@@ -2052,15 +2635,31 @@ function mutationResource(request, options0 = {}) {
2052
2635
  const ref = {
2053
2636
  ...resource,
2054
2637
  destroy: () => {
2638
+ // queue first — a late queue flush must not poke an already-destroyed resource
2639
+ queueRef.destroy();
2055
2640
  statusSub.unsubscribe();
2056
2641
  resource.destroy();
2057
- queueRef.destroy();
2058
2642
  },
2059
2643
  mutate: (value, ictx) => {
2060
2644
  if (shouldQueue) {
2061
2645
  return queue.update((q) => [...q, [value, ictx]]);
2062
2646
  }
2063
2647
  else {
2648
+ // latest-wins: a mutation already in flight gets superseded (its request is
2649
+ // aborted by the request change), so its onSuccess/onError will never fire —
2650
+ // settle its context NOW so optimistic state can be rolled back/cleaned up
2651
+ if (untracked(next) !== NULL_VALUE) {
2652
+ if (isDevMode())
2653
+ console.warn('[@mmstack/resource]: mutate() called while another mutation was in flight — the previous mutation was superseded (latest-wins) and its onSettled was invoked. Use `queue: true` for sequential mutations.');
2654
+ try {
2655
+ onSettled?.(ctx);
2656
+ }
2657
+ catch (settleErr) {
2658
+ if (isDevMode())
2659
+ console.error('[@mmstack/resource]: error thrown in onSettled hook for a superseded mutation', settleErr);
2660
+ }
2661
+ ctx = undefined;
2662
+ }
2064
2663
  try {
2065
2664
  ctx = onMutate?.(value, ictx);
2066
2665
  next.set(value);
@@ -2088,5 +2687,5 @@ function mutationResource(request, options0 = {}) {
2088
2687
  * Generated bundle index. Do not edit.
2089
2688
  */
2090
2689
 
2091
- export { Cache, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
2690
+ export { Cache, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, hashRequest, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
2092
2691
  //# sourceMappingURL=mmstack-resource.mjs.map