@mmstack/resource 22.1.1 → 22.1.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 { HttpHeaders, HttpResponse, HttpParams, HttpContextToken, HttpContext, httpResource, HttpClient } from '@angular/common/http';
2
2
  import * as i0 from '@angular/core';
3
- import { isDevMode, untracked, computed, InjectionToken, inject, signal, effect, Injector, Injectable, runInInjectionContext, DestroyRef, linkedSignal } from '@angular/core';
4
- import { mutable, toWritable, keepPrevious, sensor, injectTransitionScope, nestedEffect } from '@mmstack/primitives';
5
- import { of, tap, map, finalize, shareReplay, interval, firstValueFrom, catchError, combineLatestWith, filter } from 'rxjs';
3
+ import { isDevMode, signal, computed, untracked, InjectionToken, inject, PLATFORM_ID, DestroyRef, effect, Injector, Injectable, runInInjectionContext, linkedSignal } from '@angular/core';
4
+ import { mutable, toWritable, keepPrevious, sensor, injectTransitionScope, 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
  function createNoopDB() {
@@ -25,6 +25,8 @@ function toCacheDB(db, storeName) {
25
25
  const request = store.getAll();
26
26
  request.onsuccess = () => res(request.result);
27
27
  request.onerror = () => rej(request.error);
28
+ // some browsers abort (rather than error) e.g. on quota issues — without this the promise would stay pending forever
29
+ transaction.onabort = () => rej(transaction.error);
28
30
  })
29
31
  .then((entries) => entries.filter((e) => e.expiresAt > now))
30
32
  .catch((err) => {
@@ -40,6 +42,8 @@ function toCacheDB(db, storeName) {
40
42
  store.put(value);
41
43
  transaction.oncomplete = () => res();
42
44
  transaction.onerror = () => rej(transaction.error);
45
+ // QuotaExceededError surfaces as an abort in some browsers
46
+ transaction.onabort = () => rej(transaction.error);
43
47
  }).catch((err) => {
44
48
  if (isDevMode())
45
49
  console.error('Error storing item in cache DB:', err);
@@ -52,6 +56,7 @@ function toCacheDB(db, storeName) {
52
56
  store.delete(key);
53
57
  transaction.oncomplete = () => res();
54
58
  transaction.onerror = () => rej(transaction.error);
59
+ transaction.onabort = () => rej(transaction.error);
55
60
  }).catch((err) => {
56
61
  if (isDevMode())
57
62
  console.error('Error removing item from cache DB:', err);
@@ -68,8 +73,10 @@ function createSingleStoreDB(name, getStoreName, version = 1) {
68
73
  if (!globalThis.indexedDB)
69
74
  return Promise.resolve(createNoopDB());
70
75
  return new Promise((res, rej) => {
71
- if (version < 1)
76
+ if (version < 1) {
72
77
  rej(new Error('Version must be 1 or greater'));
78
+ return; // rej does not stop execution — without this, indexedDB.open(name, 0) still runs
79
+ }
73
80
  const req = indexedDB.open(name, version);
74
81
  req.onupgradeneeded = (event) => {
75
82
  const db = req.result;
@@ -104,6 +111,13 @@ function isSyncMessage(msg) {
104
111
  'type' in msg &&
105
112
  msg.type === 'cache-sync-message');
106
113
  }
114
+ /**
115
+ * setTimeout coerces its delay through a signed 32-bit conversion: `Infinity` becomes 0
116
+ * (immediate!) and anything above 2^31-1 ms (~24.8 days) wraps negative. Entries beyond
117
+ * this bound get NO timer and rely on lazy expiry (`expiresAt <= now` checks) plus the
118
+ * periodic sweep instead.
119
+ */
120
+ const MAX_TIMER_DELAY = 2 ** 31 - 1;
107
121
  const ONE_DAY = 1000 * 60 * 60 * 24;
108
122
  const ONE_HOUR = 1000 * 60 * 60;
109
123
  const DEFAULT_CLEANUP_OPT = {
@@ -123,9 +137,30 @@ class Cache {
123
137
  internal = mutable(new Map());
124
138
  cleanupOpt;
125
139
  id = generateID();
140
+ /** True once async hydration from the persistence layer has completed (or was empty). */
141
+ hydrated = false;
142
+ /** Keys invalidated while hydration was still in flight — must not be resurrected by it. */
143
+ hydrationTombstones = new Set();
144
+ hitCount = signal(0, /* @ts-ignore */
145
+ ...(ngDevMode ? [{ debugName: "hitCount" }] : /* istanbul ignore next */ []));
146
+ missCount = signal(0, /* @ts-ignore */
147
+ ...(ngDevMode ? [{ debugName: "missCount" }] : /* istanbul ignore next */ []));
126
148
  /**
127
- * Destroys the cache instance, cleaning up any resources used by the cache.
128
- * This method is called automatically when the cache instance is garbage collected.
149
+ * Read-only cache statistics for debugging/observability entry count plus
150
+ * request-level hit/miss counters (counted on direct lookups, e.g. the cache
151
+ * interceptor's, not on every reactive signal read). Render it in a debug
152
+ * panel; it intentionally exposes no way to mutate the cache.
153
+ */
154
+ stats = computed(() => ({
155
+ size: this.internal().size,
156
+ hits: this.hitCount(),
157
+ misses: this.missCount(),
158
+ }), /* @ts-ignore */
159
+ ...(ngDevMode ? [{ debugName: "stats" }] : /* istanbul ignore next */ []));
160
+ /**
161
+ * Destroys the cache instance, clearing the cleanup interval and closing the
162
+ * cross-tab channel. Called automatically when the providing injector is destroyed
163
+ * (wired up by `provideQueryCache`); call it manually for caches you construct yourself.
129
164
  */
130
165
  destroy;
131
166
  broadcast = () => {
@@ -142,11 +177,7 @@ class Cache {
142
177
  * @param syncTabs - If provided, the cache will use the options a BroadcastChannel to send updates between tabs.
143
178
  * Defaults to `undefined`, meaning no synchronization across tabs.
144
179
  */
145
- constructor(ttl = ONE_DAY, staleTime = ONE_HOUR, cleanupOpt = {
146
- type: 'lru',
147
- maxSize: 1000,
148
- checkInterval: ONE_HOUR,
149
- }, syncTabs, db = Promise.resolve(createNoopDB())) {
180
+ constructor(ttl = ONE_DAY, staleTime = ONE_HOUR, cleanupOpt = DEFAULT_CLEANUP_OPT, syncTabs, db = Promise.resolve(createNoopDB())) {
150
181
  this.ttl = ttl;
151
182
  this.staleTime = staleTime;
152
183
  this.db = db;
@@ -156,10 +187,12 @@ class Cache {
156
187
  };
157
188
  if (this.cleanupOpt.maxSize <= 0)
158
189
  throw new Error('maxSize must be greater than 0');
159
- // cleanup cache based on provided options regularly
160
- const cleanupInterval = setInterval(() => {
161
- this.cleanup();
162
- }, cleanupOpt.checkInterval);
190
+ // a non-finite checkInterval disables the sweeper entirely (used by the shared NoopCache)
191
+ const cleanupInterval = Number.isFinite(this.cleanupOpt.checkInterval)
192
+ ? setInterval(() => {
193
+ this.cleanup();
194
+ }, this.cleanupOpt.checkInterval)
195
+ : undefined;
163
196
  let destroySyncTabs = () => {
164
197
  // noop
165
198
  };
@@ -193,13 +226,11 @@ class Cache {
193
226
  const value = syncTabs.deserialize(msg.entry.value);
194
227
  if (value === null)
195
228
  return;
196
- // Last-write-wins by `updated` timestamp. If our local entry was
197
- // written more recently than the broadcast we just received, the
198
- // broadcast is stale (in-flight when we wrote locally) — drop it.
229
+ // Last-write-wins by `updated` timestamp.
199
230
  const existing = untracked(this.internal).get(msg.entry.key);
200
231
  if (existing && existing.updated >= msg.entry.updated)
201
232
  return;
202
- this.storeInternal(msg.entry.key, value, msg.entry.stale - msg.entry.updated, msg.entry.expiresAt - msg.entry.updated, true, false);
233
+ this.restoreInternal({ ...msg.entry, value });
203
234
  }
204
235
  else if (msg.action === 'invalidate') {
205
236
  this.invalidateInternal(msg.entry.key, true);
@@ -214,7 +245,8 @@ class Cache {
214
245
  if (destroyed)
215
246
  return;
216
247
  destroyed = true;
217
- clearInterval(cleanupInterval);
248
+ if (cleanupInterval !== undefined)
249
+ clearInterval(cleanupInterval);
218
250
  destroySyncTabs();
219
251
  };
220
252
  this.db
@@ -226,22 +258,19 @@ class Cache {
226
258
  .then((entries) => {
227
259
  if (destroyed)
228
260
  return;
229
- // load entries into the cache
230
261
  const current = untracked(this.internal);
231
262
  entries.forEach((entry) => {
232
263
  if (current.has(entry.key))
233
264
  return;
234
- this.storeInternal(entry.key, entry.value, entry.stale - entry.updated, entry.expiresAt - entry.updated, true);
265
+ // a key invalidated while hydration was in flight must stay dead
266
+ if (this.hydrationTombstones.has(entry.key))
267
+ return;
268
+ this.restoreInternal(entry);
235
269
  });
270
+ this.hydrated = true;
271
+ this.hydrationTombstones.clear();
236
272
  });
237
273
  this.destroy = destroy;
238
- // 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
239
- const registry = new FinalizationRegistry((id) => {
240
- if (id === this.id) {
241
- destroy();
242
- }
243
- });
244
- registry.register(this, this.id);
245
274
  }
246
275
  /** @internal */
247
276
  getInternal(key) {
@@ -255,22 +284,45 @@ class Cache {
255
284
  const now = Date.now();
256
285
  if (!found || found.expiresAt <= now)
257
286
  return null;
258
- found.useCount++;
259
287
  return {
260
288
  ...found,
261
289
  isStale: found.stale <= now,
262
290
  };
291
+ }, {
292
+ equal: (a, b) => a === b ||
293
+ (!!a &&
294
+ !!b &&
295
+ a.key === b.key &&
296
+ a.value === b.value &&
297
+ a.updated === b.updated &&
298
+ a.isStale === b.isStale),
263
299
  });
264
300
  }
301
+ /** @internal Imperative access bookkeeping for LRU eviction. */
302
+ touch(entry) {
303
+ entry.lastAccessed = Date.now();
304
+ entry.useCount++;
305
+ }
265
306
  /**
266
- * Retrieves a cache entry without affecting its usage count (for LRU). This is primarily
267
- * for internal use or debugging.
307
+ * Retrieves a cache entry directly (non-reactively), updating its access bookkeeping
308
+ * for LRU eviction.
268
309
  * @internal
269
310
  * @param key - The key of the entry to retrieve.
270
311
  * @returns The cache entry, or `null` if not found or expired.
271
312
  */
272
313
  getUntracked(key) {
273
- return untracked(this.getInternal(() => key));
314
+ const found = untracked(this.internal).get(key);
315
+ const now = Date.now();
316
+ if (!found || found.expiresAt <= now) {
317
+ this.missCount.update((c) => c + 1);
318
+ return null;
319
+ }
320
+ this.touch(found);
321
+ this.hitCount.update((c) => c + 1);
322
+ return {
323
+ ...found,
324
+ isStale: found.stale <= now,
325
+ };
274
326
  }
275
327
  /**
276
328
  * Retrieves a cache entry as a signal.
@@ -296,38 +348,65 @@ class Cache {
296
348
  /**
297
349
  * Stores a value in the cache.
298
350
  *
351
+ * NOTE: cached values are shared by reference across all consumers (current and
352
+ * future cache hits, persistence, cross-tab sync) — do not mutate a value after
353
+ * storing it or after reading it from the cache.
354
+ *
299
355
  * @param key - The key under which to store the value.
300
356
  * @param value - The value to store.
301
357
  * @param staleTime - (Optional) The stale time for this entry, in milliseconds. Overrides the default `staleTime`.
302
358
  * @param ttl - (Optional) The TTL for this entry, in milliseconds. Overrides the default `ttl`.
359
+ * @param persist - (Optional) Whether to also write the entry to the persistence layer (IndexedDB). Defaults to `false`.
303
360
  */
304
361
  store(key, value, staleTime = this.staleTime, ttl = this.ttl, persist = false) {
305
362
  this.storeInternal(key, value, staleTime, ttl, false, persist);
306
363
  }
307
364
  storeInternal(key, value, staleTime = this.staleTime, ttl = this.ttl, fromSync = false, persist = false) {
308
- const entry = this.getUntracked(key);
309
- if (entry) {
310
- clearTimeout(entry.timeout); // stop invalidation
311
- }
312
- const prevCount = entry?.useCount ?? 0;
365
+ const entry = untracked(this.internal).get(key);
313
366
  // ttl cannot be less than staleTime
314
367
  if (ttl < staleTime)
315
368
  staleTime = ttl;
316
369
  const now = Date.now();
317
- const next = {
370
+ this.setEntry({
318
371
  value,
319
372
  created: entry?.created ?? now,
320
373
  updated: now,
321
- useCount: prevCount + 1,
374
+ useCount: (entry?.useCount ?? 0) + 1,
375
+ lastAccessed: now,
322
376
  stale: now + staleTime,
323
377
  expiresAt: now + ttl,
324
378
  key,
325
- };
379
+ }, fromSync, persist);
380
+ }
381
+ /**
382
+ * @internal
383
+ * Inserts an entry that already carries ABSOLUTE timestamps — hydration from the
384
+ * persistence layer and cross-tab sync messages. Never re-anchors freshness to
385
+ * `Date.now()`, never persists, never broadcasts.
386
+ */
387
+ restoreInternal(entry) {
388
+ this.setEntry({
389
+ ...entry,
390
+ // rows persisted by older versions may lack the field
391
+ lastAccessed: entry.lastAccessed ?? entry.updated,
392
+ }, true, false);
393
+ }
394
+ /** @internal Shared writer: arms the expiry timer only within the safe delay range. */
395
+ setEntry(next, fromSync, persist) {
396
+ const existing = untracked(this.internal).get(next.key);
397
+ if (existing)
398
+ clearTimeout(existing.timeout); // stop the previous invalidation
399
+ const remaining = next.expiresAt - Date.now();
400
+ // already expired (clock skew on a synced/restored entry) — don't insert
401
+ if (remaining <= 0)
402
+ return;
403
+ // Infinity (immutable) or > 2^31-1 would coerce to an IMMEDIATE timeout — such
404
+ // entries get no timer and rely on lazy expiry + the periodic sweep instead
405
+ const timeout = Number.isFinite(remaining) && remaining <= MAX_TIMER_DELAY
406
+ ? setTimeout(() => this.invalidate(next.key), remaining)
407
+ : undefined;
326
408
  this.internal.mutate((map) => {
327
- map.set(key, {
328
- ...next,
329
- timeout: setTimeout(() => this.invalidate(key), ttl),
330
- });
409
+ map.set(next.key, { ...next, timeout });
331
410
  return map;
332
411
  });
333
412
  if (!fromSync) {
@@ -373,32 +452,55 @@ class Cache {
373
452
  return keys.length;
374
453
  }
375
454
  invalidateInternal(key, fromSync = false) {
376
- const entry = this.getUntracked(key);
377
- if (!entry)
378
- return;
379
- clearTimeout(entry.timeout);
380
- this.internal.mutate((map) => {
381
- map.delete(key);
382
- return map;
383
- });
455
+ // a key invalidated before async hydration completes must not be resurrected by it
456
+ if (!this.hydrated)
457
+ this.hydrationTombstones.add(key);
458
+ const entry = untracked(this.internal).get(key);
459
+ if (entry) {
460
+ clearTimeout(entry.timeout);
461
+ this.internal.mutate((map) => {
462
+ map.delete(key);
463
+ return map;
464
+ });
465
+ }
384
466
  if (!fromSync) {
385
467
  this.db.then((db) => db.remove(key));
386
468
  this.broadcast({ action: 'invalidate', entry: { key } });
387
469
  }
388
470
  }
389
- /** @internal */
471
+ /**
472
+ * Removes EVERY entry — memory, persisted rows, and (via broadcast) other tabs.
473
+ * Call on logout/auth changes so no prior user's responses survive.
474
+ */
475
+ clear() {
476
+ for (const key of Array.from(untracked(this.internal).keys())) {
477
+ this.invalidateInternal(key);
478
+ }
479
+ }
480
+ /** @internal Drops expired entries, then enforces `maxSize` by the configured strategy. */
390
481
  cleanup() {
482
+ const now = Date.now();
483
+ // expired entries first — their timers may never have fired (throttled background
484
+ // tabs, or timer-less long-TTL entries)
485
+ const expired = Array.from(untracked(this.internal).entries()).filter(([, e]) => e.expiresAt <= now);
486
+ if (expired.length) {
487
+ expired.forEach(([, e]) => clearTimeout(e.timeout));
488
+ this.internal.mutate((map) => {
489
+ expired.forEach(([key]) => map.delete(key));
490
+ return map;
491
+ });
492
+ }
391
493
  if (untracked(this.internal).size <= this.cleanupOpt.maxSize)
392
494
  return;
393
495
  const sorted = Array.from(untracked(this.internal).entries()).toSorted((a, b) => {
394
496
  if (this.cleanupOpt.type === 'lru') {
395
- return a[1].useCount - b[1].useCount; // least used first
497
+ return a[1].lastAccessed - b[1].lastAccessed; // least recently accessed first
396
498
  }
397
499
  else {
398
500
  return a[1].created - b[1].created; // oldest first
399
501
  }
400
502
  });
401
- const keepCount = Math.floor(this.cleanupOpt.maxSize / 2);
503
+ const keepCount = Math.max(1, Math.floor(this.cleanupOpt.maxSize / 2));
402
504
  const removed = sorted.slice(0, sorted.length - keepCount);
403
505
  const keep = sorted.slice(removed.length, sorted.length);
404
506
  removed.forEach(([, e]) => {
@@ -443,7 +545,8 @@ function provideQueryCache(opt) {
443
545
  return JSON.stringify({
444
546
  body: value.body,
445
547
  status: value.status,
446
- statusText: value.statusText,
548
+ // statusText intentionally omitted: deprecated in Angular, meaningless under
549
+ // HTTP/2+ (HttpResponse defaults it to 'OK' on reconstruction)
447
550
  headers: headerKeys.length > 0 ? headersRecord : undefined,
448
551
  url: value.url,
449
552
  });
@@ -459,7 +562,6 @@ function provideQueryCache(opt) {
459
562
  return new HttpResponse({
460
563
  body: parsed.body,
461
564
  status: parsed.status,
462
- statusText: parsed.statusText,
463
565
  headers: headers,
464
566
  url: parsed.url,
465
567
  });
@@ -470,49 +572,72 @@ function provideQueryCache(opt) {
470
572
  return null;
471
573
  }
472
574
  };
473
- const syncTabsOpt = opt?.syncTabs
474
- ? {
475
- id: 'mmstack-query-cache-sync',
476
- serialize,
477
- deserialize,
478
- }
479
- : undefined;
480
- const db = opt?.persist === false
481
- ? undefined
482
- : createSingleStoreDB('mmstack-query-cache-db', (version) => `query-store_v${version}`, opt?.version).then((db) => {
483
- return {
484
- getAll: () => {
485
- return db.getAll().then((entries) => {
486
- return entries
487
- .map((entry) => {
488
- const value = deserialize(entry.value);
489
- if (value === null)
490
- return null;
491
- return {
492
- ...entry,
493
- value,
494
- };
495
- })
496
- .filter((e) => e !== null);
497
- });
498
- },
499
- store: (entry) => {
500
- return db.store({ ...entry, value: serialize(entry.value) });
501
- },
502
- remove: db.remove,
503
- };
504
- });
575
+ // version-suffixed so two deploys with incompatible schemas in adjacent tabs don't
576
+ // push entries into each other's caches (the `version` option only fences IndexedDB)
577
+ const syncChannelId = `mmstack-query-cache-sync_v${opt?.version ?? 1}`;
505
578
  return {
506
579
  provide: CLIENT_CACHE_TOKEN,
507
- useValue: new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup, syncTabsOpt, db),
580
+ useFactory: () => {
581
+ const onServer = inject(PLATFORM_ID) === 'server';
582
+ // no IndexedDB / BroadcastChannel on the server — each request gets an
583
+ // isolated, request-lived, memory-only cache
584
+ const syncTabsOpt = !onServer && opt?.syncTabs
585
+ ? {
586
+ id: syncChannelId,
587
+ serialize,
588
+ deserialize,
589
+ }
590
+ : undefined;
591
+ const db = onServer || opt?.persist === false
592
+ ? undefined
593
+ : createSingleStoreDB('mmstack-query-cache-db', (version) => `query-store_v${version}`, opt?.version).then((db) => {
594
+ return {
595
+ getAll: () => {
596
+ return db.getAll().then((entries) => {
597
+ return entries
598
+ .map((entry) => {
599
+ const value = deserialize(entry.value);
600
+ if (value === null)
601
+ return null;
602
+ return {
603
+ ...entry,
604
+ value,
605
+ };
606
+ })
607
+ .filter((e) => e !== null);
608
+ });
609
+ },
610
+ store: (entry) => {
611
+ return db.store({ ...entry, value: serialize(entry.value) });
612
+ },
613
+ remove: db.remove,
614
+ };
615
+ });
616
+ const cache = new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup, syncTabsOpt, db);
617
+ // release the sweep interval / channel with the providing injector
618
+ inject(DestroyRef, { optional: true })?.onDestroy(() => cache.destroy());
619
+ return cache;
620
+ },
508
621
  };
509
622
  }
510
623
  class NoopCache extends Cache {
624
+ constructor() {
625
+ // Infinity checkInterval → no sweep interval is ever armed, so the shared
626
+ // instance below never pins a timer
627
+ super(undefined, undefined, {
628
+ type: 'lru',
629
+ maxSize: 200,
630
+ checkInterval: Infinity,
631
+ });
632
+ }
511
633
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
512
634
  store(_, __, ___ = super.staleTime, ____ = super.ttl) {
513
635
  // noop
514
636
  }
515
637
  }
638
+ // one shared instance — minting a NoopCache per injectQueryCache() miss would leak
639
+ // an instance (and previously an interval) on every prod call without a provider
640
+ let NOOP_CACHE;
516
641
  /**
517
642
  * Injects the `QueryCache` instance that is used within queryResource.
518
643
  * Allows for direct modification of cached data, but is mostly meant for internal use.
@@ -547,10 +672,21 @@ function injectQueryCache(injector) {
547
672
  if (isDevMode())
548
673
  throw new Error('Cache not provided, please add provideQueryCache() to providers array');
549
674
  else
550
- return new NoopCache();
675
+ return (NOOP_CACHE ??= new NoopCache());
551
676
  }
552
677
  return cache;
553
678
  }
679
+ /**
680
+ * Injects the cache statistics, including the current size of the cache and the number of hits and misses.
681
+ *
682
+ * @param injector - (Optional) The injector to use. If not provided, the current
683
+ * injection context is used.
684
+ * @returns A signal containing the cache statistics.
685
+ */
686
+ function injectCacheStats(injector) {
687
+ const cache = injectQueryCache(injector);
688
+ return cache.stats;
689
+ }
554
690
 
555
691
  /**
556
692
  * Returns `true` for any object-like value whose own enumerable keys should
@@ -654,14 +790,121 @@ function hash(...args) {
654
790
  return hashKey(args);
655
791
  }
656
792
 
793
+ /**
794
+ * @internal
795
+ * One-way ~64-bit digest from two independent FNV-1a passes. Used for header VALUES in
796
+ * cache keys: keys are persisted (IndexedDB) and broadcast cross-tab, so raw values
797
+ * (auth tokens!) must never appear in them. A single 32-bit digest's 2^-32 collision
798
+ * chance is too thin at a security boundary — two colliding tokens would serve one
799
+ * user's cached data under another user's key; 64 bits puts collisions out of reach.
800
+ * High-entropy secrets are not recoverable from the digest.
801
+ */
802
+ function digestHeaderValue(value) {
803
+ let h1 = 0x811c9dc5; // FNV-1a offset basis
804
+ let h2 = 0xcbf29ce4; // independent second pass
805
+ for (let i = 0; i < value.length; i++) {
806
+ const c = value.charCodeAt(i);
807
+ h1 = Math.imul(h1 ^ c, 0x01000193); // FNV prime
808
+ h2 = Math.imul(h2 ^ c, 0x01000197); // distinct odd multiplier
809
+ }
810
+ return ((h1 >>> 0).toString(16).padStart(8, '0') +
811
+ (h2 >>> 0).toString(16).padStart(8, '0'));
812
+ }
813
+ function readHeader(headers, name) {
814
+ if (!headers)
815
+ return null;
816
+ if (headers instanceof HttpHeaders) {
817
+ const all = headers.getAll(name);
818
+ return all && all.length ? all.join(',') : null;
819
+ }
820
+ // record form — header names are case-insensitive
821
+ const lower = name.toLowerCase();
822
+ for (const key of Object.keys(headers)) {
823
+ if (key.toLowerCase() !== lower)
824
+ continue;
825
+ const value = headers[key];
826
+ if (value == null)
827
+ return null;
828
+ return Array.isArray(value) ? value.join(',') : String(value);
829
+ }
830
+ return null;
831
+ }
832
+ /**
833
+ * Content-negotiation headers whose values are low-entropy and non-identifying —
834
+ * embedded (URI-encoded) raw, keeping keys human-readable and skipping the digest.
835
+ * Anything NOT on this list (Authorization, api keys, tenant/x-* headers — we can't
836
+ * know what they carry) is one-way digested instead.
837
+ */
838
+ const SAFE_RAW_HEADERS = new Set([
839
+ 'accept',
840
+ 'accept-language',
841
+ 'content-language',
842
+ 'content-type',
843
+ ]);
844
+ const UNSAFE_HEADER_MESSAGES = new Map([
845
+ [
846
+ 'cookie',
847
+ "[@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.",
848
+ ],
849
+ [
850
+ 'set-cookie',
851
+ "[@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.",
852
+ ],
853
+ [
854
+ 'authorization',
855
+ "[@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.",
856
+ ],
857
+ [
858
+ 'x-request-id',
859
+ "[@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.",
860
+ ],
861
+ [
862
+ 'x-correlation-id',
863
+ "[@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.",
864
+ ],
865
+ [
866
+ 'if-none-match',
867
+ "[@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.",
868
+ ],
869
+ [
870
+ 'if-modified-since',
871
+ "[@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.",
872
+ ],
873
+ ]);
874
+ function normalizeVaryHeaders(headers, names) {
875
+ const isDev = isDevMode();
876
+ return names
877
+ .map((n) => n.toLowerCase())
878
+ .toSorted()
879
+ .map((name) => {
880
+ if (isDev) {
881
+ const warning = UNSAFE_HEADER_MESSAGES.get(name);
882
+ if (warning)
883
+ console.warn(warning);
884
+ }
885
+ const value = readHeader(headers, name);
886
+ if (value === null)
887
+ return `${name}=`;
888
+ // known-safe values raw (readable, cheap); everything else digested, NEVER raw —
889
+ // keys are persisted to IndexedDB and broadcast across tabs
890
+ return SAFE_RAW_HEADERS.has(name)
891
+ ? `${name}=${encodeURIComponent(value)}`
892
+ : `${name}=${digestHeaderValue(value)}`;
893
+ })
894
+ .join('&');
895
+ }
657
896
  function normalizeParams(params) {
658
- const p = params instanceof HttpParams ? params : new HttpParams({ fromObject: params });
897
+ const p = params instanceof HttpParams
898
+ ? params
899
+ : new HttpParams({ fromObject: params });
659
900
  return p
660
901
  .keys()
661
902
  .toSorted()
662
903
  .map((key) => {
663
904
  const encodedKey = encodeURIComponent(key);
664
- return (p.getAll(key) ?? []).map((v) => `${encodedKey}=${encodeURIComponent(v)}`).join('&');
905
+ return (p.getAll(key) ?? [])
906
+ .map((v) => `${encodedKey}=${encodeURIComponent(v)}`)
907
+ .join('&');
665
908
  })
666
909
  .join('&');
667
910
  }
@@ -681,7 +924,8 @@ function hashBody(body) {
681
924
  entries.sort(([ak, av], [bk, bv]) => ak.localeCompare(bk) || av.localeCompare(bv));
682
925
  return `FormData:${entries.map(([k, v]) => `${k}=${v}`).join('&')}`;
683
926
  }
684
- if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
927
+ if (typeof URLSearchParams !== 'undefined' &&
928
+ body instanceof URLSearchParams) {
685
929
  const sp = new URLSearchParams(body);
686
930
  sp.sort();
687
931
  return `URLSearchParams:${sp.toString()}`;
@@ -698,20 +942,48 @@ function hashBody(body) {
698
942
  * Builds a stable cache/dedupe key from an HTTP request shape (accepts both
699
943
  * `HttpRequest` and `HttpResourceRequest`).
700
944
  *
701
- * Key composition: `${method}:${url}:${responseType}[:${params}][:${body}]`
945
+ * Key composition: `${method}:${url}:${responseType}[:${params}][:${body}][:${vary}]`
702
946
  * - `method` defaults to `'GET'`, `responseType` to `'json'` (Angular defaults).
703
947
  * - Query params are sorted alphabetically and URL-encoded for stability.
704
948
  * - Body hashing handles `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`
705
949
  * and typed arrays explicitly; everything else flows through key-sorted
706
950
  * `JSON.stringify` via `hash()`.
951
+ * - `varyHeaders` (opt-in) mixes the named request headers into the key so responses
952
+ * that differ per header (e.g. `Authorization` → per-user, `Accept-Language`) get
953
+ * separate entries. Known-safe content-negotiation headers (`Accept`,
954
+ * `Accept-Language`, `Content-Language`, `Content-Type`) embed their value raw for
955
+ * readable keys; all other header VALUES are one-way digested, never embedded raw —
956
+ * keys are persisted to IndexedDB and broadcast across tabs.
707
957
  */
708
- function hashRequest(req) {
958
+ function hashRequest(req, varyHeaders) {
709
959
  const method = req.method ?? 'GET';
710
960
  const responseType = req.responseType ?? 'json';
711
961
  const base = `${method}:${req.url}:${responseType}`;
712
962
  const params = req.params ? `:${normalizeParams(req.params)}` : '';
713
963
  const body = req.body != null ? `:${hashBody(req.body)}` : '';
714
- return base + params + body;
964
+ const vary = varyHeaders?.length
965
+ ? `:vary(${normalizeVaryHeaders(req.headers, varyHeaders)})`
966
+ : '';
967
+ return base + params + body + vary;
968
+ }
969
+
970
+ /**
971
+ * @internal
972
+ * Single-flight sharing: if a pending observable is already registered under `key`,
973
+ * return it; otherwise create one, share it (replaying the latest event to late
974
+ * subscribers), and deregister it on teardown/settle.
975
+ *
976
+ * Used by both the dedupe interceptor (keyed by full request hash, app-wide) and the
977
+ * cache interceptor (keyed by the CACHE key, guarding the miss/stale-revalidation path)
978
+ * — same mechanism, different keying/scope, so it lives here exactly once.
979
+ */
980
+ function sharePending(pending, key, create) {
981
+ const existing = pending.get(key);
982
+ if (existing)
983
+ return existing;
984
+ const shared = create().pipe(finalize(() => pending.delete(key)), shareReplay({ bufferSize: 1, refCount: true }));
985
+ pending.set(key, shared);
986
+ return shared;
715
987
  }
716
988
 
717
989
  const CACHE_CONTEXT = new HttpContextToken(() => ({
@@ -731,6 +1003,7 @@ function parseCacheControlHeader(req) {
731
1003
  noCache: false,
732
1004
  mustRevalidate: false,
733
1005
  immutable: false,
1006
+ isPrivate: false,
734
1007
  maxAge: null,
735
1008
  staleWhileRevalidate: null,
736
1009
  };
@@ -754,6 +1027,9 @@ function parseCacheControlHeader(req) {
754
1027
  case 'immutable':
755
1028
  directives.immutable = true;
756
1029
  break;
1030
+ case 'private':
1031
+ directives.isPrivate = true;
1032
+ break;
757
1033
  case 'max-age': {
758
1034
  if (!value)
759
1035
  break;
@@ -762,7 +1038,7 @@ function parseCacheControlHeader(req) {
762
1038
  directives.maxAge = parsedValue;
763
1039
  break;
764
1040
  }
765
- case 's-max-age': {
1041
+ case 's-maxage': {
766
1042
  if (!value)
767
1043
  break;
768
1044
  const parsedValue = parseInt(value, 10);
@@ -780,7 +1056,7 @@ function parseCacheControlHeader(req) {
780
1056
  }
781
1057
  }
782
1058
  }
783
- // s-max-age takes precedence over max-age
1059
+ // s-maxage takes precedence over max-age
784
1060
  if (sMaxAge !== null)
785
1061
  directives.maxAge = sMaxAge;
786
1062
  // if no store nothing else is relevant
@@ -790,6 +1066,7 @@ function parseCacheControlHeader(req) {
790
1066
  noCache: false,
791
1067
  mustRevalidate: false,
792
1068
  immutable: false,
1069
+ isPrivate: directives.isPrivate,
793
1070
  maxAge: null,
794
1071
  staleWhileRevalidate: null,
795
1072
  };
@@ -809,14 +1086,32 @@ function resolveTimings(cacheControl, optStaleTime, optTTL) {
809
1086
  staleTime: Infinity,
810
1087
  ttl: Infinity,
811
1088
  };
812
- if (cacheControl.maxAge !== null)
813
- ttl = cacheControl.maxAge * 1000;
814
- if (cacheControl.staleWhileRevalidate !== null)
815
- staleTime = cacheControl.staleWhileRevalidate * 1000;
816
- // if no-cache is set, we must always revalidate
1089
+ if (cacheControl.maxAge !== null) {
1090
+ staleTime = cacheControl.maxAge * 1000;
1091
+ if (cacheControl.staleWhileRevalidate !== null) {
1092
+ ttl = staleTime + cacheControl.staleWhileRevalidate * 1000;
1093
+ }
1094
+ else if (ttl !== undefined) {
1095
+ // a configured total lifetime must never undercut the server's fresh window
1096
+ ttl = Math.max(ttl, staleTime);
1097
+ }
1098
+ // no swr + no configured ttl → leave undefined so the cache's default ttl applies
1099
+ // (the entry stays resident past max-age for ETag revalidation)
1100
+ }
1101
+ else if (cacheControl.staleWhileRevalidate !== null) {
1102
+ // swr without max-age: stale immediately, revalidatable for the window
1103
+ staleTime = 0;
1104
+ ttl = cacheControl.staleWhileRevalidate * 1000;
1105
+ }
1106
+ // if no-cache is set, we must always revalidate (the entry stays usable for conditional requests until ttl)
817
1107
  if (cacheControl.noCache || cacheControl.mustRevalidate)
818
1108
  staleTime = 0;
819
- if (ttl !== undefined && staleTime !== undefined && ttl < staleTime) {
1109
+ // option-only path (no server freshness): a misconfigured ttl < staleTime clamps the
1110
+ // fresh window down, mirroring the cache's own internal clamp
1111
+ if (cacheControl.maxAge === null &&
1112
+ ttl !== undefined &&
1113
+ staleTime !== undefined &&
1114
+ ttl < staleTime) {
820
1115
  staleTime = ttl;
821
1116
  }
822
1117
  return { staleTime, ttl };
@@ -830,6 +1125,10 @@ function resolveTimings(cacheControl, optStaleTime, optTTL) {
830
1125
  * is made to the server, and the response is cached according to the configured TTL and staleness.
831
1126
  * The interceptor also respects `Cache-Control` headers from the server.
832
1127
  *
1128
+ * Cache-enabled requests are single-flighted per cache key: N concurrent consumers of
1129
+ * the same missing/stale entry share ONE network request. Non-cached requests are not
1130
+ * touched — pair with `createDedupeRequestsInterceptor` to coalesce those as well.
1131
+ *
833
1132
  * @param allowedMethods - An array of HTTP methods for which caching should be enabled.
834
1133
  * Defaults to `['GET', 'HEAD', 'OPTIONS']`.
835
1134
  *
@@ -850,7 +1149,10 @@ function resolveTimings(cacheControl, optStaleTime, optTTL) {
850
1149
  */
851
1150
  function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
852
1151
  const CACHE_METHODS = new Set(allowedMethods);
1152
+ const inFlight = new Map();
853
1153
  return (req, next) => {
1154
+ if (inject(PLATFORM_ID) === 'server')
1155
+ return next(req);
854
1156
  const cache = injectQueryCache();
855
1157
  if (!CACHE_METHODS.has(req.method))
856
1158
  return next(req);
@@ -863,60 +1165,78 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
863
1165
  if (entry && !entry.isStale)
864
1166
  return of(entry.value);
865
1167
  // resource itself handles case of showing stale data...the request must process as this will "refresh said data"
866
- const eTag = entry?.value.headers.get('ETag');
867
- const lastModified = entry?.value.headers.get('Last-Modified');
868
- if (eTag) {
869
- req = req.clone({ setHeaders: { 'If-None-Match': eTag } });
870
- }
871
- if (lastModified) {
872
- req = req.clone({ setHeaders: { 'If-Modified-Since': lastModified } });
873
- }
874
- if (opt.bustBrowserCache) {
875
- req = req.clone({
876
- setParams: { _cb: Date.now().toString() },
877
- });
878
- }
879
- return next(req).pipe(tap((event) => {
880
- if (!(event instanceof HttpResponse))
881
- return;
882
- if (event.ok) {
883
- const cacheControl = parseCacheControlHeader(event);
884
- if (cacheControl.noStore && !opt.ignoreCacheControl)
885
- return;
886
- const { staleTime, ttl } = opt.ignoreCacheControl
887
- ? opt
888
- : resolveTimings(cacheControl, opt.staleTime, opt.ttl);
889
- if (opt.ttl === 0)
890
- return; // no point
891
- const parsedResponse = opt.parse
892
- ? new HttpResponse({
893
- body: opt.parse(event.body),
894
- headers: event.headers,
895
- status: event.status,
896
- statusText: event.statusText,
897
- url: event.url ?? undefined,
898
- })
899
- : event;
900
- cache.store(key, parsedResponse, staleTime, ttl, opt.persist);
901
- return;
1168
+ return sharePending(inFlight, key, () => {
1169
+ const eTag = entry?.value.headers.get('ETag');
1170
+ const lastModified = entry?.value.headers.get('Last-Modified');
1171
+ if (eTag) {
1172
+ req = req.clone({ setHeaders: { 'If-None-Match': eTag } });
1173
+ }
1174
+ if (lastModified) {
1175
+ req = req.clone({ setHeaders: { 'If-Modified-Since': lastModified } });
902
1176
  }
903
- // 304 → server confirmed our cached entry is still valid. Re-stamp the
904
- // existing entry so subsequent reads within the new freshness window
905
- // don't trigger another revalidation round-trip.
906
- if (event.status === 304 && entry) {
907
- const cacheControl = parseCacheControlHeader(event);
908
- const { staleTime, ttl } = opt.ignoreCacheControl
909
- ? opt
910
- : resolveTimings(cacheControl, opt.staleTime, opt.ttl);
911
- cache.store(key, entry.value, staleTime, ttl, opt.persist);
1177
+ if (opt.bustBrowserCache) {
1178
+ req = req.clone({
1179
+ setParams: { _cb: Date.now().toString() },
1180
+ });
912
1181
  }
913
- }), map((event) => {
914
- // handle 304 responses due to eTag/last-modified
915
- if (event instanceof HttpResponse && event.status === 304 && entry) {
916
- return entry.value;
1182
+ // non-JSON bodies (blob/arraybuffer) cannot survive the JSON persistence layer
1183
+ const persistable = req.responseType === 'json';
1184
+ if (opt.persist && !persistable && isDevMode()) {
1185
+ console.warn(`[@mmstack/resource]: persist was requested for a '${req.responseType}' response — such bodies don't survive JSON serialization, persisting skipped.`);
917
1186
  }
918
- return event;
919
- }));
1187
+ return next(req).pipe(tap((event) => {
1188
+ if (!(event instanceof HttpResponse))
1189
+ return;
1190
+ if (event.ok) {
1191
+ const cacheControl = parseCacheControlHeader(event);
1192
+ if (cacheControl.noStore && !opt.ignoreCacheControl)
1193
+ return;
1194
+ const { staleTime, ttl } = opt.ignoreCacheControl
1195
+ ? opt
1196
+ : resolveTimings(cacheControl, opt.staleTime, opt.ttl);
1197
+ if (ttl === 0)
1198
+ return; // no point
1199
+ // `Cache-Control: private` → fine to keep in memory, never on disk
1200
+ const persist = (opt.persist ?? false) &&
1201
+ persistable &&
1202
+ (opt.ignoreCacheControl || !cacheControl.isPrivate);
1203
+ const parsedResponse = opt.parse
1204
+ ? // statusText omitted — deprecated in Angular (HttpResponse defaults it)
1205
+ new HttpResponse({
1206
+ body: opt.parse(event.body),
1207
+ headers: event.headers,
1208
+ status: event.status,
1209
+ url: event.url ?? undefined,
1210
+ })
1211
+ : event;
1212
+ cache.store(key, parsedResponse, staleTime, ttl, persist);
1213
+ return;
1214
+ }
1215
+ // 304 → server confirmed our cached entry is still valid. Re-stamp the
1216
+ // existing entry so subsequent reads within the new freshness window
1217
+ // don't trigger another revalidation round-trip.
1218
+ if (event.status === 304 && entry) {
1219
+ // ...unless the key was invalidated while this conditional request was in
1220
+ // flight (e.g. by a mutation) — re-storing would resurrect deleted data
1221
+ if (!cache.getUntracked(key))
1222
+ return;
1223
+ const cacheControl = parseCacheControlHeader(event);
1224
+ const { staleTime, ttl } = opt.ignoreCacheControl
1225
+ ? opt
1226
+ : resolveTimings(cacheControl, opt.staleTime, opt.ttl);
1227
+ const persist = (opt.persist ?? false) &&
1228
+ persistable &&
1229
+ (opt.ignoreCacheControl || !cacheControl.isPrivate);
1230
+ cache.store(key, entry.value, staleTime, ttl, persist);
1231
+ }
1232
+ }), map((event) => {
1233
+ // handle 304 responses due to eTag/last-modified
1234
+ if (event instanceof HttpResponse && event.status === 304 && entry) {
1235
+ return entry.value;
1236
+ }
1237
+ return event;
1238
+ }));
1239
+ });
920
1240
  };
921
1241
  }
922
1242
 
@@ -1145,6 +1465,12 @@ function noDedupe(ctx = new HttpContext()) {
1145
1465
  * only the first request will be sent to the server. Subsequent requests will
1146
1466
  * receive the response from the first request.
1147
1467
  *
1468
+ * Relationship to `createCacheInterceptor`: the cache interceptor has built-in
1469
+ * single-flight for CACHE-ENABLED requests (keyed by the cache key). This interceptor
1470
+ * covers everything the cache doesn't see — non-cached resources, plain HttpClient
1471
+ * calls, DELETEs — keyed by the request hash. Installing both is the recommended
1472
+ * setup; where they overlap, this one degrades to a no-op passthrough.
1473
+ *
1148
1474
  * @param allowed - An array of HTTP methods for which deduplication should be enabled.
1149
1475
  * Defaults to `['GET', 'DELETE', 'HEAD', 'OPTIONS']`.
1150
1476
  * @param keyFn - Optional function to compute the dedupe key from a request.
@@ -1179,13 +1505,7 @@ function createDedupeRequestsInterceptor(allowed = ['GET', 'DELETE', 'HEAD', 'OP
1179
1505
  return (req, next) => {
1180
1506
  if (!DEDUPE_METHODS.has(req.method) || req.context.get(NO_DEDUPE))
1181
1507
  return next(req);
1182
- const key = keyFn(req);
1183
- const found = inFlight.get(key);
1184
- if (found)
1185
- return found;
1186
- const request = next(req).pipe(finalize(() => inFlight.delete(key)), shareReplay({ bufferSize: 1, refCount: true }));
1187
- inFlight.set(key, request);
1188
- return request;
1508
+ return sharePending(inFlight, keyFn(req), () => next(req));
1189
1509
  };
1190
1510
  }
1191
1511
 
@@ -1358,6 +1678,57 @@ function hasSlowConnection() {
1358
1678
  return false;
1359
1679
  }
1360
1680
 
1681
+ /**
1682
+ * Deep merges multiple circuit breaker options.
1683
+ * The latter options override the former.
1684
+ */
1685
+ function mergeCircuitBreakerOptions(global, query, local) {
1686
+ if (!global && !query && !local)
1687
+ return undefined;
1688
+ return {
1689
+ ...(global === true ? {} : global),
1690
+ ...(query === true ? {} : query),
1691
+ ...(local === true ? {} : local),
1692
+ };
1693
+ }
1694
+ /**
1695
+ * Deep merges multiple retry options.
1696
+ * The latter options override the former.
1697
+ */
1698
+ function mergeRetryOptions(global, query, local) {
1699
+ if (global === undefined && query === undefined && local === undefined)
1700
+ return undefined;
1701
+ return {
1702
+ ...(typeof global === 'number' ? { max: global } : global),
1703
+ ...(typeof query === 'number' ? { max: query } : query),
1704
+ ...(typeof local === 'number' ? { max: local } : local),
1705
+ };
1706
+ }
1707
+ /**
1708
+ * Deep merges multiple cache options.
1709
+ * The latter options override the former.
1710
+ */
1711
+ function mergeCacheOptions(query, local) {
1712
+ if (query === undefined && local === undefined)
1713
+ return undefined;
1714
+ return {
1715
+ ...(query === true ? {} : query),
1716
+ ...(local === true ? {} : local),
1717
+ };
1718
+ }
1719
+ /**
1720
+ * Deep merges multiple refresh options.
1721
+ * The latter options override the former.
1722
+ */
1723
+ function mergeRefreshOptions(query, local) {
1724
+ if (query === undefined && local === undefined)
1725
+ return undefined;
1726
+ return {
1727
+ ...(typeof query === 'number' ? { interval: query } : query),
1728
+ ...(typeof local === 'number' ? { interval: local } : local),
1729
+ };
1730
+ }
1731
+
1361
1732
  function persistResourceValues(resource, shouldPersist = false, equal) {
1362
1733
  if (!shouldPersist)
1363
1734
  return resource;
@@ -1369,24 +1740,61 @@ function persistResourceValues(resource, shouldPersist = false, equal) {
1369
1740
  };
1370
1741
  }
1371
1742
 
1372
- // refresh resource every n miliseconds or don't refresh if undefined provided. 0 also excluded, due to it not being a valid usecase.
1373
- function refresh(resource, destroyRef, refresh, inactive) {
1374
- if (!refresh)
1743
+ // refresh resource every n milliseconds and/or on visibility/reconnect transitions.
1744
+ function refresh(resource, destroyRef, opt, inactive, triggers) {
1745
+ const normalized = typeof opt === 'number' ? { interval: opt } : (opt ?? {});
1746
+ const { interval: ms, onFocus = false, onReconnect = false, } = normalized;
1747
+ const hasInterval = !!ms; // 0 excluded — not a valid polling cadence
1748
+ const hasTriggerEffects = !!triggers && (onFocus || onReconnect);
1749
+ if (!hasInterval && !hasTriggerEffects)
1375
1750
  return resource; // no refresh requested
1376
1751
  const tick = () => {
1377
1752
  if (inactive?.())
1378
- return; // disabled / paused → skip the poll
1753
+ return; // disabled / paused → skip
1379
1754
  resource.reload();
1380
1755
  };
1756
+ const effectRefs = [];
1757
+ if (triggers && onFocus) {
1758
+ const vis = triggers.visibility;
1759
+ let prev = untracked(vis);
1760
+ effectRefs.push(effect(() => {
1761
+ const next = vis();
1762
+ const was = prev;
1763
+ prev = next;
1764
+ // only the hidden → visible TRANSITION refreshes — not the initial run
1765
+ if (was !== 'visible' && next === 'visible')
1766
+ untracked(tick);
1767
+ }, { injector: triggers.injector }));
1768
+ }
1769
+ if (triggers && onReconnect) {
1770
+ const online = triggers.online;
1771
+ let prev = untracked(online);
1772
+ effectRefs.push(effect(() => {
1773
+ const next = online();
1774
+ const was = prev;
1775
+ prev = next;
1776
+ if (!was && next)
1777
+ untracked(tick);
1778
+ }, { injector: triggers.injector }));
1779
+ }
1780
+ if (!hasInterval) {
1781
+ return {
1782
+ ...resource,
1783
+ destroy: () => {
1784
+ effectRefs.forEach((ref) => ref.destroy());
1785
+ resource.destroy();
1786
+ },
1787
+ };
1788
+ }
1381
1789
  // 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.
1382
- let sub = interval(refresh)
1790
+ let sub = interval(ms)
1383
1791
  .pipe(takeUntilDestroyed(destroyRef))
1384
1792
  .subscribe(tick);
1385
1793
  const reload = () => {
1386
1794
  sub.unsubscribe(); // do not conflict with manual reload
1387
1795
  const hasReloaded = resource.reload();
1388
1796
  // resubscribe after manual reload
1389
- sub = interval(refresh)
1797
+ sub = interval(ms)
1390
1798
  .pipe(takeUntilDestroyed(destroyRef))
1391
1799
  .subscribe(tick);
1392
1800
  return hasReloaded;
@@ -1396,6 +1804,7 @@ function refresh(resource, destroyRef, refresh, inactive) {
1396
1804
  reload,
1397
1805
  destroy: () => {
1398
1806
  sub.unsubscribe();
1807
+ effectRefs.forEach((ref) => ref.destroy());
1399
1808
  resource.destroy();
1400
1809
  },
1401
1810
  };
@@ -1443,6 +1852,7 @@ function retryOnError(res, opt, onError) {
1443
1852
 
1444
1853
  class ResourceSensors {
1445
1854
  networkStatus = sensor('networkStatus');
1855
+ pageVisibility = sensor('pageVisibility');
1446
1856
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: ResourceSensors, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1447
1857
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: ResourceSensors, providedIn: 'root' });
1448
1858
  }
@@ -1455,6 +1865,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImpor
1455
1865
  function injectNetworkStatus() {
1456
1866
  return inject(ResourceSensors).networkStatus;
1457
1867
  }
1868
+ function injectPageVisibility() {
1869
+ return inject(ResourceSensors).pageVisibility;
1870
+ }
1458
1871
 
1459
1872
  function toResourceObject(res) {
1460
1873
  return {
@@ -1536,10 +1949,16 @@ function injectQueryResourceOptions(injector) {
1536
1949
  const PAUSED = Symbol('@mmstack/resource:paused');
1537
1950
  function queryResource(request, options0) {
1538
1951
  // Two-layer option injection: per-call > provideQueryResourceOptions > provideResourceOptions.
1952
+ const globalOpts = injectResourceOptions(options0?.injector);
1953
+ const queryOpts = injectQueryResourceOptions(options0?.injector);
1539
1954
  const options = {
1540
- ...injectResourceOptions(options0?.injector),
1541
- ...injectQueryResourceOptions(options0?.injector),
1955
+ ...globalOpts,
1956
+ ...queryOpts,
1542
1957
  ...options0,
1958
+ cache: mergeCacheOptions(queryOpts.cache, options0?.cache),
1959
+ circuitBreaker: mergeCircuitBreakerOptions(globalOpts.circuitBreaker, queryOpts.circuitBreaker, options0?.circuitBreaker),
1960
+ retry: mergeRetryOptions(globalOpts.retry, queryOpts.retry, options0?.retry),
1961
+ refresh: mergeRefreshOptions(queryOpts.refresh, options0?.refresh),
1543
1962
  };
1544
1963
  const cache = injectQueryCache(options?.injector);
1545
1964
  const destroyRef = options?.injector
@@ -1552,10 +1971,20 @@ function queryResource(request, options0) {
1552
1971
  const eq = options?.triggerOnSameRequest
1553
1972
  ? undefined
1554
1973
  : (options?.equalRequest ?? createEqualRequest());
1974
+ // Opt-in auto-pausing: `true` reads the ambient Activity boundary (no-op outside
1975
+ // one), a predicate is used directly. Composes with the manual `ctx.paused` path.
1976
+ const pauseOpt = options?.pause ?? false;
1977
+ const externallyPaused = pauseOpt === false
1978
+ ? () => false
1979
+ : typeof pauseOpt === 'function'
1980
+ ? pauseOpt
1981
+ : options?.injector
1982
+ ? runInInjectionContext(options.injector, injectPaused)
1983
+ : injectPaused();
1555
1984
  const requestCtx = { paused: PAUSED };
1556
1985
  const rawResult = computed(() => request(requestCtx), /* @ts-ignore */
1557
1986
  ...(ngDevMode ? [{ debugName: "rawResult" }] : /* istanbul ignore next */ []));
1558
- const paused = computed(() => rawResult() === PAUSED, /* @ts-ignore */
1987
+ const paused = computed(() => rawResult() === PAUSED || externallyPaused(), /* @ts-ignore */
1559
1988
  ...(ngDevMode ? [{ debugName: "paused" }] : /* istanbul ignore next */ []));
1560
1989
  const rawRequest = computed(() => {
1561
1990
  const r = rawResult();
@@ -1567,9 +1996,12 @@ function queryResource(request, options0) {
1567
1996
  return 'offline';
1568
1997
  if (cb.isOpen())
1569
1998
  return 'circuit-open';
1570
- // PAUSED makes rawRequest undefined, so it reports 'no-request' here (and skips polling),
1571
- // while stableRequest below HOLDS the last request so the value is kept (no refetch on resume).
1572
- if (!rawRequest())
1999
+ // Both pause sources report 'no-request' here ctx.paused makes rawRequest
2000
+ // undefined, while the external `pause` option still yields a real request, so it
2001
+ // must be checked explicitly. Either way this also stops polling/refresh triggers
2002
+ // (their inactive() guard reads disabledReason), while stableRequest below HOLDS
2003
+ // the last request so the value is kept (no refetch on resume).
2004
+ if (paused() || !rawRequest())
1573
2005
  return 'no-request';
1574
2006
  return null;
1575
2007
  }, /* @ts-ignore */
@@ -1600,8 +2032,10 @@ function queryResource(request, options0) {
1600
2032
  return eq(a, b);
1601
2033
  return a === b;
1602
2034
  } });
2035
+ const varyHeaders = typeof options?.cache === 'object' ? options.cache.varyHeaders : undefined;
1603
2036
  const hashFn = typeof options?.cache === 'object'
1604
- ? (options.cache.hash ?? hashRequest)
2037
+ ? (options.cache.hash ??
2038
+ ((r) => hashRequest(r, varyHeaders)))
1605
2039
  : hashRequest;
1606
2040
  const staleTime = typeof options?.cache === 'object' ? options.cache.staleTime : 0;
1607
2041
  const ttl = typeof options?.cache === 'object' ? options.cache.ttl : undefined;
@@ -1661,22 +2095,30 @@ function queryResource(request, options0) {
1661
2095
  key: entry.key,
1662
2096
  };
1663
2097
  } });
1664
- // A disabled (offline / circuit-open / no-request) or PAUSED resource must not poll.
1665
- resource = refresh(resource, destroyRef, options?.refresh, () => disabledReason() !== null);
2098
+ // A disabled (offline / circuit-open / no-request) or PAUSED resource must not poll
2099
+ // or react to focus/reconnect.
2100
+ resource = refresh(resource, destroyRef, options?.refresh, () => disabledReason() !== null, {
2101
+ injector: options?.injector ?? inject(Injector),
2102
+ visibility: injectPageVisibility(),
2103
+ online: networkAvailable,
2104
+ });
1666
2105
  resource = retryOnError(resource, options?.retry, options?.onError);
1667
2106
  resource = persistResourceValues(resource, options?.keepPrevious, options?.equal);
1668
2107
  const set = (value) => {
1669
2108
  resource.value.set(value);
1670
2109
  const k = untracked(cacheKey);
1671
2110
  if (options?.cache && k)
1672
- cache.store(k, new HttpResponse({
2111
+ cache.store(k,
2112
+ // statusText omitted — deprecated in Angular (HttpResponse defaults it)
2113
+ new HttpResponse({
1673
2114
  body: value,
1674
2115
  status: 200,
1675
- statusText: 'OK',
1676
2116
  }), staleTime, ttl, persist);
1677
2117
  };
1678
2118
  const update = (updater) => {
1679
- set(updater(untracked(resource.value)));
2119
+ // baseline on the COMPOSED value (cache-preferring): the cache entry can be newer
2120
+ // than resource.value (cross-tab sync, another instance's set)
2121
+ set(updater(untracked(value)));
1680
2122
  };
1681
2123
  const value = options?.cache
1682
2124
  ? toWritable(computed(() => cacheEntry()?.value ?? resource.value()), set, update)
@@ -1766,6 +2208,106 @@ function queryResource(request, options0) {
1766
2208
  return ref;
1767
2209
  }
1768
2210
 
2211
+ /**
2212
+ * Creates a paginated HTTP resource over {@link queryResource}: one page request at a
2213
+ * time, accumulated into a `pages` signal — cursor- and offset-based pagination both
2214
+ * fit through `getNextPageParam`. Each page request inherits the full queryResource
2215
+ * feature set (caching per page, retries, circuit breaker, refresh triggers).
2216
+ *
2217
+ * @example
2218
+ * ```ts
2219
+ * const posts = infiniteQueryResource<PostPage, PostPage, number>(
2220
+ * ({ pageParam }) => ({ url: '/api/posts', params: { page: pageParam } }),
2221
+ * {
2222
+ * initialPageParam: 0,
2223
+ * getNextPageParam: (last, all) => (last.items.length < 20 ? null : all.length),
2224
+ * cache: true,
2225
+ * },
2226
+ * );
2227
+ *
2228
+ * // template:
2229
+ * // @for (page of posts.pages(); track $index) { ... }
2230
+ * // <button (click)="posts.fetchNextPage()" [disabled]="!posts.hasNextPage()">More</button>
2231
+ * const flat = computed(() => posts.pages().flatMap((p) => p.items));
2232
+ * ```
2233
+ */
2234
+ function infiniteQueryResource(request, options) {
2235
+ const { initialPageParam, getNextPageParam, ...rest } = options;
2236
+ const injector = options.injector ?? inject(Injector);
2237
+ const pageParam = signal(initialPageParam, /* @ts-ignore */
2238
+ ...(ngDevMode ? [{ debugName: "pageParam" }] : /* istanbul ignore next */ []));
2239
+ // pages keyed by the param that produced them, so a reload of an already-loaded
2240
+ // page REPLACES its slot instead of appending a duplicate
2241
+ const loaded = signal([], /* @ts-ignore */
2242
+ ...(ngDevMode ? [{ debugName: "loaded" }] : /* istanbul ignore next */ []));
2243
+ const resource = queryResource(
2244
+ // forward queryResource's own context so the fn can return ctx.paused —
2245
+ // pausing holds the loaded pages and stops page fetches until unpaused
2246
+ (qctx) => request({ ...qctx, pageParam: pageParam() }), { ...rest, injector });
2247
+ const appendRef = effect(() => {
2248
+ if (resource.status() !== 'resolved')
2249
+ return;
2250
+ const page = resource.value();
2251
+ if (page === undefined)
2252
+ return;
2253
+ untracked(() => {
2254
+ const param = pageParam();
2255
+ loaded.update((list) => {
2256
+ const idx = list.findIndex((e) => Object.is(e.param, param));
2257
+ if (idx >= 0) {
2258
+ const copy = [...list];
2259
+ copy[idx] = { param, page };
2260
+ return copy;
2261
+ }
2262
+ return [...list, { param, page }];
2263
+ });
2264
+ });
2265
+ }, { ...(ngDevMode ? { debugName: "appendRef" } : /* istanbul ignore next */ {}), injector });
2266
+ const pages = computed(() => loaded().map((e) => e.page), /* @ts-ignore */
2267
+ ...(ngDevMode ? [{ debugName: "pages" }] : /* istanbul ignore next */ []));
2268
+ const nextPageParam = computed(() => {
2269
+ const all = pages();
2270
+ if (all.length === 0)
2271
+ return null;
2272
+ return getNextPageParam(all[all.length - 1], all) ?? null;
2273
+ }, /* @ts-ignore */
2274
+ ...(ngDevMode ? [{ debugName: "nextPageParam" }] : /* istanbul ignore next */ []));
2275
+ const hasNextPage = computed(() => nextPageParam() !== null, /* @ts-ignore */
2276
+ ...(ngDevMode ? [{ debugName: "hasNextPage" }] : /* istanbul ignore next */ []));
2277
+ const fetchNextPage = () => {
2278
+ if (untracked(resource.isLoading))
2279
+ return; // one page at a time
2280
+ const next = untracked(nextPageParam);
2281
+ if (next === null)
2282
+ return;
2283
+ pageParam.set(next);
2284
+ };
2285
+ const reset = () => {
2286
+ loaded.set([]);
2287
+ if (Object.is(untracked(pageParam), initialPageParam)) {
2288
+ resource.reload(); // param unchanged — force the refetch
2289
+ }
2290
+ else {
2291
+ pageParam.set(initialPageParam);
2292
+ }
2293
+ };
2294
+ return {
2295
+ pages,
2296
+ hasNextPage,
2297
+ isFetchingNextPage: computed(() => resource.isLoading() && loaded().length > 0),
2298
+ isLoading: resource.isLoading,
2299
+ status: resource.status,
2300
+ error: resource.error,
2301
+ fetchNextPage,
2302
+ reload: () => resource.reload(),
2303
+ reset,
2304
+ destroy: () => {
2305
+ appendRef.destroy();
2306
+ resource.destroy();
2307
+ },
2308
+ };
2309
+ }
2310
+
1769
2311
  function manualQueryResource(request, options) {
1770
2312
  const trigger = signal({ epoch: 0 }, { ...(ngDevMode ? { debugName: "trigger" } : /* istanbul ignore next */ {}), equal: (a, b) => a.epoch === b.epoch });
1771
2313
  const injector = options?.injector ?? inject(Injector);
@@ -1778,6 +2320,12 @@ function manualQueryResource(request, options) {
1778
2320
  return untracked(request);
1779
2321
  }, { ...(ngDevMode ? { debugName: "req" } : /* istanbul ignore next */ {}), equal: () => false });
1780
2322
  const resource = queryResource(req, options);
2323
+ // Shared across trigger() calls: a per-call watcher could observe the PREVIOUS
2324
+ // request's `resolved` status before this trigger's load flips the resource to
2325
+ // loading (effect ordering within a flush is unspecified) and resolve with stale
2326
+ // data; concurrent triggers would also cross-resolve each other's promises.
2327
+ let pending = [];
2328
+ let watcher = null;
1781
2329
  return {
1782
2330
  ...resource,
1783
2331
  trigger: (override, injectorOverride) => {
@@ -1786,15 +2334,41 @@ function manualQueryResource(request, options) {
1786
2334
  override,
1787
2335
  }));
1788
2336
  return new Promise((res, rej) => {
1789
- const watcher = nestedEffect(() => {
2337
+ if (untracked(req) === undefined) {
2338
+ // the request fn produced nothing — no load will ever start, so a watcher
2339
+ // would hang this promise forever
2340
+ rej(new Error('[@mmstack/resource]: trigger() produced no request (the request fn returned undefined)'));
2341
+ return;
2342
+ }
2343
+ pending.push({ res, rej });
2344
+ // an active watcher (concurrent trigger) settles ALL pending promises with
2345
+ // the final result of the latest request — TanStack-style latest-wins
2346
+ if (watcher)
2347
+ return;
2348
+ // only accept a settle AFTER the load for this trigger has been observed —
2349
+ // the pre-trigger status may still be a stale `resolved`/`error`
2350
+ let sawLoading = false;
2351
+ watcher = nestedEffect(() => {
1790
2352
  const status = resource.status();
1791
- if (status === 'resolved') {
1792
- watcher.destroy();
1793
- res(untracked(resource.value));
2353
+ if (status === 'loading' || status === 'reloading') {
2354
+ sawLoading = true;
2355
+ return;
1794
2356
  }
1795
- else if (status === 'error') {
1796
- watcher.destroy();
1797
- rej(untracked(resource.error));
2357
+ if (!sawLoading)
2358
+ return;
2359
+ if (status === 'resolved' || status === 'error') {
2360
+ const settled = pending;
2361
+ pending = [];
2362
+ watcher?.destroy();
2363
+ watcher = null;
2364
+ if (status === 'resolved') {
2365
+ const value = untracked(resource.value);
2366
+ settled.forEach((p) => p.res(value));
2367
+ }
2368
+ else {
2369
+ const err = untracked(resource.error);
2370
+ settled.forEach((p) => p.rej(err));
2371
+ }
1798
2372
  }
1799
2373
  }, { injector: injectorOverride ?? injector });
1800
2374
  });
@@ -1867,14 +2441,19 @@ function injectMutationResourceOptions(injector) {
1867
2441
  */
1868
2442
  function mutationResource(request, options0 = {}) {
1869
2443
  // Two-layer option injection: per-call > provideMutationResourceOptions > provideResourceOptions.
2444
+ const globalOpts = injectResourceOptions(options0.injector);
2445
+ const mutOpts = injectMutationResourceOptions(options0.injector);
1870
2446
  const options = {
1871
- ...injectResourceOptions(options0.injector),
1872
- ...injectMutationResourceOptions(options0.injector),
2447
+ ...globalOpts,
2448
+ ...mutOpts,
1873
2449
  ...options0,
2450
+ circuitBreaker: mergeCircuitBreakerOptions(globalOpts.circuitBreaker, mutOpts.circuitBreaker, options0?.circuitBreaker),
2451
+ retry: mergeRetryOptions(globalOpts.retry, mutOpts.retry, options0?.retry),
1874
2452
  };
1875
2453
  // `register` is pulled out (and forced off on the inner query below) so the mutation ref is
1876
2454
  // the only thing registered into the transition scope, not its internal query resource.
1877
- const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, ...rest } = options;
2455
+ const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, invalidates, ...rest } = options;
2456
+ const cache = invalidates ? injectQueryCache(options.injector) : undefined;
1878
2457
  const requestEqual = equalRequest ?? createEqualRequest(equal);
1879
2458
  // A mutation is an imperative command, so `triggerOnSameRequest` means "fire on EVERY mutate(),
1880
2459
  // even with an identical body". By default we dedup an identical value/request while one is in
@@ -1979,8 +2558,19 @@ function mutationResource(request, options0 = {}) {
1979
2558
  .subscribe((result) => {
1980
2559
  if (result.status === 'error')
1981
2560
  onError?.(result.error, ctx);
1982
- else
2561
+ else {
1983
2562
  onSuccess?.(result.value, ctx);
2563
+ if (cache && invalidates) {
2564
+ const mutation = untracked(lastValue);
2565
+ const prefixes = typeof invalidates === 'function'
2566
+ ? invalidates(result.value, (mutation === NULL_VALUE ? undefined : mutation))
2567
+ : invalidates;
2568
+ // auto-keys are `${method}:${url}:...` — a `GET:`-prefixed url prefix hits
2569
+ // the url with any params/subpaths and every varyHeaders variant
2570
+ for (const prefix of prefixes)
2571
+ cache.invalidatePrefix(`GET:${prefix}`);
2572
+ }
2573
+ }
1984
2574
  onSettled?.(ctx);
1985
2575
  ctx = undefined;
1986
2576
  next.set(NULL_VALUE);
@@ -1989,15 +2579,31 @@ function mutationResource(request, options0 = {}) {
1989
2579
  const ref = {
1990
2580
  ...resource,
1991
2581
  destroy: () => {
2582
+ // queue first — a late queue flush must not poke an already-destroyed resource
2583
+ queueRef.destroy();
1992
2584
  statusSub.unsubscribe();
1993
2585
  resource.destroy();
1994
- queueRef.destroy();
1995
2586
  },
1996
2587
  mutate: (value, ictx) => {
1997
2588
  if (shouldQueue) {
1998
2589
  return queue.update((q) => [...q, [value, ictx]]);
1999
2590
  }
2000
2591
  else {
2592
+ // latest-wins: a mutation already in flight gets superseded (its request is
2593
+ // aborted by the request change), so its onSuccess/onError will never fire —
2594
+ // settle its context NOW so optimistic state can be rolled back/cleaned up
2595
+ if (untracked(next) !== NULL_VALUE) {
2596
+ if (isDevMode())
2597
+ 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.');
2598
+ try {
2599
+ onSettled?.(ctx);
2600
+ }
2601
+ catch (settleErr) {
2602
+ if (isDevMode())
2603
+ console.error('[@mmstack/resource]: error thrown in onSettled hook for a superseded mutation', settleErr);
2604
+ }
2605
+ ctx = undefined;
2606
+ }
2001
2607
  try {
2002
2608
  ctx = onMutate?.(value, ictx);
2003
2609
  next.set(value);
@@ -2025,5 +2631,5 @@ function mutationResource(request, options0 = {}) {
2025
2631
  * Generated bundle index. Do not edit.
2026
2632
  */
2027
2633
 
2028
- export { Cache, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
2634
+ export { Cache, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, hashRequest, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
2029
2635
  //# sourceMappingURL=mmstack-resource.mjs.map