@mmstack/resource 22.1.0 → 22.1.2

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,6 +790,76 @@ 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
+ function normalizeVaryHeaders(headers, names) {
845
+ return names
846
+ .map((n) => n.toLowerCase())
847
+ .toSorted()
848
+ .map((name) => {
849
+ if (isDevMode() && (name === 'cookie' || name === 'set-cookie')) {
850
+ console.warn(`[@mmstack/resource]: varyHeaders includes '${name}'. 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.`);
851
+ }
852
+ const value = readHeader(headers, name);
853
+ if (value === null)
854
+ return `${name}=`;
855
+ // known-safe values raw (readable, cheap); everything else digested, NEVER raw —
856
+ // keys are persisted to IndexedDB and broadcast across tabs
857
+ return SAFE_RAW_HEADERS.has(name)
858
+ ? `${name}=${encodeURIComponent(value)}`
859
+ : `${name}=${digestHeaderValue(value)}`;
860
+ })
861
+ .join('&');
862
+ }
657
863
  function normalizeParams(params) {
658
864
  const p = params instanceof HttpParams ? params : new HttpParams({ fromObject: params });
659
865
  return p
@@ -698,20 +904,48 @@ function hashBody(body) {
698
904
  * Builds a stable cache/dedupe key from an HTTP request shape (accepts both
699
905
  * `HttpRequest` and `HttpResourceRequest`).
700
906
  *
701
- * Key composition: `${method}:${url}:${responseType}[:${params}][:${body}]`
907
+ * Key composition: `${method}:${url}:${responseType}[:${params}][:${body}][:${vary}]`
702
908
  * - `method` defaults to `'GET'`, `responseType` to `'json'` (Angular defaults).
703
909
  * - Query params are sorted alphabetically and URL-encoded for stability.
704
910
  * - Body hashing handles `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`
705
911
  * and typed arrays explicitly; everything else flows through key-sorted
706
912
  * `JSON.stringify` via `hash()`.
913
+ * - `varyHeaders` (opt-in) mixes the named request headers into the key so responses
914
+ * that differ per header (e.g. `Authorization` → per-user, `Accept-Language`) get
915
+ * separate entries. Known-safe content-negotiation headers (`Accept`,
916
+ * `Accept-Language`, `Content-Language`, `Content-Type`) embed their value raw for
917
+ * readable keys; all other header VALUES are one-way digested, never embedded raw —
918
+ * keys are persisted to IndexedDB and broadcast across tabs.
707
919
  */
708
- function hashRequest(req) {
920
+ function hashRequest(req, varyHeaders) {
709
921
  const method = req.method ?? 'GET';
710
922
  const responseType = req.responseType ?? 'json';
711
923
  const base = `${method}:${req.url}:${responseType}`;
712
924
  const params = req.params ? `:${normalizeParams(req.params)}` : '';
713
925
  const body = req.body != null ? `:${hashBody(req.body)}` : '';
714
- return base + params + body;
926
+ const vary = varyHeaders?.length
927
+ ? `:vary(${normalizeVaryHeaders(req.headers, varyHeaders)})`
928
+ : '';
929
+ return base + params + body + vary;
930
+ }
931
+
932
+ /**
933
+ * @internal
934
+ * Single-flight sharing: if a pending observable is already registered under `key`,
935
+ * return it; otherwise create one, share it (replaying the latest event to late
936
+ * subscribers), and deregister it on teardown/settle.
937
+ *
938
+ * Used by both the dedupe interceptor (keyed by full request hash, app-wide) and the
939
+ * cache interceptor (keyed by the CACHE key, guarding the miss/stale-revalidation path)
940
+ * — same mechanism, different keying/scope, so it lives here exactly once.
941
+ */
942
+ function sharePending(pending, key, create) {
943
+ const existing = pending.get(key);
944
+ if (existing)
945
+ return existing;
946
+ const shared = create().pipe(finalize(() => pending.delete(key)), shareReplay({ bufferSize: 1, refCount: true }));
947
+ pending.set(key, shared);
948
+ return shared;
715
949
  }
716
950
 
717
951
  const CACHE_CONTEXT = new HttpContextToken(() => ({
@@ -731,6 +965,7 @@ function parseCacheControlHeader(req) {
731
965
  noCache: false,
732
966
  mustRevalidate: false,
733
967
  immutable: false,
968
+ isPrivate: false,
734
969
  maxAge: null,
735
970
  staleWhileRevalidate: null,
736
971
  };
@@ -754,6 +989,9 @@ function parseCacheControlHeader(req) {
754
989
  case 'immutable':
755
990
  directives.immutable = true;
756
991
  break;
992
+ case 'private':
993
+ directives.isPrivate = true;
994
+ break;
757
995
  case 'max-age': {
758
996
  if (!value)
759
997
  break;
@@ -762,7 +1000,7 @@ function parseCacheControlHeader(req) {
762
1000
  directives.maxAge = parsedValue;
763
1001
  break;
764
1002
  }
765
- case 's-max-age': {
1003
+ case 's-maxage': {
766
1004
  if (!value)
767
1005
  break;
768
1006
  const parsedValue = parseInt(value, 10);
@@ -780,7 +1018,7 @@ function parseCacheControlHeader(req) {
780
1018
  }
781
1019
  }
782
1020
  }
783
- // s-max-age takes precedence over max-age
1021
+ // s-maxage takes precedence over max-age
784
1022
  if (sMaxAge !== null)
785
1023
  directives.maxAge = sMaxAge;
786
1024
  // if no store nothing else is relevant
@@ -790,6 +1028,7 @@ function parseCacheControlHeader(req) {
790
1028
  noCache: false,
791
1029
  mustRevalidate: false,
792
1030
  immutable: false,
1031
+ isPrivate: directives.isPrivate,
793
1032
  maxAge: null,
794
1033
  staleWhileRevalidate: null,
795
1034
  };
@@ -809,14 +1048,32 @@ function resolveTimings(cacheControl, optStaleTime, optTTL) {
809
1048
  staleTime: Infinity,
810
1049
  ttl: Infinity,
811
1050
  };
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
1051
+ if (cacheControl.maxAge !== null) {
1052
+ staleTime = cacheControl.maxAge * 1000;
1053
+ if (cacheControl.staleWhileRevalidate !== null) {
1054
+ ttl = staleTime + cacheControl.staleWhileRevalidate * 1000;
1055
+ }
1056
+ else if (ttl !== undefined) {
1057
+ // a configured total lifetime must never undercut the server's fresh window
1058
+ ttl = Math.max(ttl, staleTime);
1059
+ }
1060
+ // no swr + no configured ttl → leave undefined so the cache's default ttl applies
1061
+ // (the entry stays resident past max-age for ETag revalidation)
1062
+ }
1063
+ else if (cacheControl.staleWhileRevalidate !== null) {
1064
+ // swr without max-age: stale immediately, revalidatable for the window
1065
+ staleTime = 0;
1066
+ ttl = cacheControl.staleWhileRevalidate * 1000;
1067
+ }
1068
+ // if no-cache is set, we must always revalidate (the entry stays usable for conditional requests until ttl)
817
1069
  if (cacheControl.noCache || cacheControl.mustRevalidate)
818
1070
  staleTime = 0;
819
- if (ttl !== undefined && staleTime !== undefined && ttl < staleTime) {
1071
+ // option-only path (no server freshness): a misconfigured ttl < staleTime clamps the
1072
+ // fresh window down, mirroring the cache's own internal clamp
1073
+ if (cacheControl.maxAge === null &&
1074
+ ttl !== undefined &&
1075
+ staleTime !== undefined &&
1076
+ ttl < staleTime) {
820
1077
  staleTime = ttl;
821
1078
  }
822
1079
  return { staleTime, ttl };
@@ -830,6 +1087,10 @@ function resolveTimings(cacheControl, optStaleTime, optTTL) {
830
1087
  * is made to the server, and the response is cached according to the configured TTL and staleness.
831
1088
  * The interceptor also respects `Cache-Control` headers from the server.
832
1089
  *
1090
+ * Cache-enabled requests are single-flighted per cache key: N concurrent consumers of
1091
+ * the same missing/stale entry share ONE network request. Non-cached requests are not
1092
+ * touched — pair with `createDedupeRequestsInterceptor` to coalesce those as well.
1093
+ *
833
1094
  * @param allowedMethods - An array of HTTP methods for which caching should be enabled.
834
1095
  * Defaults to `['GET', 'HEAD', 'OPTIONS']`.
835
1096
  *
@@ -850,7 +1111,10 @@ function resolveTimings(cacheControl, optStaleTime, optTTL) {
850
1111
  */
851
1112
  function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
852
1113
  const CACHE_METHODS = new Set(allowedMethods);
1114
+ const inFlight = new Map();
853
1115
  return (req, next) => {
1116
+ if (inject(PLATFORM_ID) === 'server')
1117
+ return next(req);
854
1118
  const cache = injectQueryCache();
855
1119
  if (!CACHE_METHODS.has(req.method))
856
1120
  return next(req);
@@ -863,60 +1127,78 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
863
1127
  if (entry && !entry.isStale)
864
1128
  return of(entry.value);
865
1129
  // 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;
1130
+ return sharePending(inFlight, key, () => {
1131
+ const eTag = entry?.value.headers.get('ETag');
1132
+ const lastModified = entry?.value.headers.get('Last-Modified');
1133
+ if (eTag) {
1134
+ req = req.clone({ setHeaders: { 'If-None-Match': eTag } });
1135
+ }
1136
+ if (lastModified) {
1137
+ req = req.clone({ setHeaders: { 'If-Modified-Since': lastModified } });
902
1138
  }
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);
1139
+ if (opt.bustBrowserCache) {
1140
+ req = req.clone({
1141
+ setParams: { _cb: Date.now().toString() },
1142
+ });
912
1143
  }
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;
1144
+ // non-JSON bodies (blob/arraybuffer) cannot survive the JSON persistence layer
1145
+ const persistable = req.responseType === 'json';
1146
+ if (opt.persist && !persistable && isDevMode()) {
1147
+ console.warn(`[@mmstack/resource]: persist was requested for a '${req.responseType}' response — such bodies don't survive JSON serialization, persisting skipped.`);
917
1148
  }
918
- return event;
919
- }));
1149
+ return next(req).pipe(tap((event) => {
1150
+ if (!(event instanceof HttpResponse))
1151
+ return;
1152
+ if (event.ok) {
1153
+ const cacheControl = parseCacheControlHeader(event);
1154
+ if (cacheControl.noStore && !opt.ignoreCacheControl)
1155
+ return;
1156
+ const { staleTime, ttl } = opt.ignoreCacheControl
1157
+ ? opt
1158
+ : resolveTimings(cacheControl, opt.staleTime, opt.ttl);
1159
+ if (ttl === 0)
1160
+ return; // no point
1161
+ // `Cache-Control: private` → fine to keep in memory, never on disk
1162
+ const persist = (opt.persist ?? false) &&
1163
+ persistable &&
1164
+ (opt.ignoreCacheControl || !cacheControl.isPrivate);
1165
+ const parsedResponse = opt.parse
1166
+ ? // statusText omitted — deprecated in Angular (HttpResponse defaults it)
1167
+ new HttpResponse({
1168
+ body: opt.parse(event.body),
1169
+ headers: event.headers,
1170
+ status: event.status,
1171
+ url: event.url ?? undefined,
1172
+ })
1173
+ : event;
1174
+ cache.store(key, parsedResponse, staleTime, ttl, persist);
1175
+ return;
1176
+ }
1177
+ // 304 → server confirmed our cached entry is still valid. Re-stamp the
1178
+ // existing entry so subsequent reads within the new freshness window
1179
+ // don't trigger another revalidation round-trip.
1180
+ if (event.status === 304 && entry) {
1181
+ // ...unless the key was invalidated while this conditional request was in
1182
+ // flight (e.g. by a mutation) — re-storing would resurrect deleted data
1183
+ if (!cache.getUntracked(key))
1184
+ return;
1185
+ const cacheControl = parseCacheControlHeader(event);
1186
+ const { staleTime, ttl } = opt.ignoreCacheControl
1187
+ ? opt
1188
+ : resolveTimings(cacheControl, opt.staleTime, opt.ttl);
1189
+ const persist = (opt.persist ?? false) &&
1190
+ persistable &&
1191
+ (opt.ignoreCacheControl || !cacheControl.isPrivate);
1192
+ cache.store(key, entry.value, staleTime, ttl, persist);
1193
+ }
1194
+ }), map((event) => {
1195
+ // handle 304 responses due to eTag/last-modified
1196
+ if (event instanceof HttpResponse && event.status === 304 && entry) {
1197
+ return entry.value;
1198
+ }
1199
+ return event;
1200
+ }));
1201
+ });
920
1202
  };
921
1203
  }
922
1204
 
@@ -1145,6 +1427,12 @@ function noDedupe(ctx = new HttpContext()) {
1145
1427
  * only the first request will be sent to the server. Subsequent requests will
1146
1428
  * receive the response from the first request.
1147
1429
  *
1430
+ * Relationship to `createCacheInterceptor`: the cache interceptor has built-in
1431
+ * single-flight for CACHE-ENABLED requests (keyed by the cache key). This interceptor
1432
+ * covers everything the cache doesn't see — non-cached resources, plain HttpClient
1433
+ * calls, DELETEs — keyed by the request hash. Installing both is the recommended
1434
+ * setup; where they overlap, this one degrades to a no-op passthrough.
1435
+ *
1148
1436
  * @param allowed - An array of HTTP methods for which deduplication should be enabled.
1149
1437
  * Defaults to `['GET', 'DELETE', 'HEAD', 'OPTIONS']`.
1150
1438
  * @param keyFn - Optional function to compute the dedupe key from a request.
@@ -1179,13 +1467,7 @@ function createDedupeRequestsInterceptor(allowed = ['GET', 'DELETE', 'HEAD', 'OP
1179
1467
  return (req, next) => {
1180
1468
  if (!DEDUPE_METHODS.has(req.method) || req.context.get(NO_DEDUPE))
1181
1469
  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;
1470
+ return sharePending(inFlight, keyFn(req), () => next(req));
1189
1471
  };
1190
1472
  }
1191
1473
 
@@ -1369,24 +1651,61 @@ function persistResourceValues(resource, shouldPersist = false, equal) {
1369
1651
  };
1370
1652
  }
1371
1653
 
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)
1654
+ // refresh resource every n milliseconds and/or on visibility/reconnect transitions.
1655
+ function refresh(resource, destroyRef, opt, inactive, triggers) {
1656
+ const normalized = typeof opt === 'number' ? { interval: opt } : (opt ?? {});
1657
+ const { interval: ms, onFocus = false, onReconnect = false, } = normalized;
1658
+ const hasInterval = !!ms; // 0 excluded — not a valid polling cadence
1659
+ const hasTriggerEffects = !!triggers && (onFocus || onReconnect);
1660
+ if (!hasInterval && !hasTriggerEffects)
1375
1661
  return resource; // no refresh requested
1376
1662
  const tick = () => {
1377
1663
  if (inactive?.())
1378
- return; // disabled / paused → skip the poll
1664
+ return; // disabled / paused → skip
1379
1665
  resource.reload();
1380
1666
  };
1667
+ const effectRefs = [];
1668
+ if (triggers && onFocus) {
1669
+ const vis = triggers.visibility;
1670
+ let prev = untracked(vis);
1671
+ effectRefs.push(effect(() => {
1672
+ const next = vis();
1673
+ const was = prev;
1674
+ prev = next;
1675
+ // only the hidden → visible TRANSITION refreshes — not the initial run
1676
+ if (was !== 'visible' && next === 'visible')
1677
+ untracked(tick);
1678
+ }, { injector: triggers.injector }));
1679
+ }
1680
+ if (triggers && onReconnect) {
1681
+ const online = triggers.online;
1682
+ let prev = untracked(online);
1683
+ effectRefs.push(effect(() => {
1684
+ const next = online();
1685
+ const was = prev;
1686
+ prev = next;
1687
+ if (!was && next)
1688
+ untracked(tick);
1689
+ }, { injector: triggers.injector }));
1690
+ }
1691
+ if (!hasInterval) {
1692
+ return {
1693
+ ...resource,
1694
+ destroy: () => {
1695
+ effectRefs.forEach((ref) => ref.destroy());
1696
+ resource.destroy();
1697
+ },
1698
+ };
1699
+ }
1381
1700
  // 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)
1701
+ let sub = interval(ms)
1383
1702
  .pipe(takeUntilDestroyed(destroyRef))
1384
1703
  .subscribe(tick);
1385
1704
  const reload = () => {
1386
1705
  sub.unsubscribe(); // do not conflict with manual reload
1387
1706
  const hasReloaded = resource.reload();
1388
1707
  // resubscribe after manual reload
1389
- sub = interval(refresh)
1708
+ sub = interval(ms)
1390
1709
  .pipe(takeUntilDestroyed(destroyRef))
1391
1710
  .subscribe(tick);
1392
1711
  return hasReloaded;
@@ -1396,6 +1715,7 @@ function refresh(resource, destroyRef, refresh, inactive) {
1396
1715
  reload,
1397
1716
  destroy: () => {
1398
1717
  sub.unsubscribe();
1718
+ effectRefs.forEach((ref) => ref.destroy());
1399
1719
  resource.destroy();
1400
1720
  },
1401
1721
  };
@@ -1443,6 +1763,7 @@ function retryOnError(res, opt, onError) {
1443
1763
 
1444
1764
  class ResourceSensors {
1445
1765
  networkStatus = sensor('networkStatus');
1766
+ pageVisibility = sensor('pageVisibility');
1446
1767
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: ResourceSensors, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1447
1768
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: ResourceSensors, providedIn: 'root' });
1448
1769
  }
@@ -1455,6 +1776,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImpor
1455
1776
  function injectNetworkStatus() {
1456
1777
  return inject(ResourceSensors).networkStatus;
1457
1778
  }
1779
+ function injectPageVisibility() {
1780
+ return inject(ResourceSensors).pageVisibility;
1781
+ }
1458
1782
 
1459
1783
  function toResourceObject(res) {
1460
1784
  return {
@@ -1500,7 +1824,7 @@ function provideTypedResourceOptions(token, valueOrFn) {
1500
1824
  function applyResourceRegistration(ref, register, injector) {
1501
1825
  if (!register)
1502
1826
  return;
1503
- const opt = register === true ? { suspends: false } : register;
1827
+ const opt = { suspends: register === 'suspend' };
1504
1828
  const run = injector
1505
1829
  ? (fn) => runInInjectionContext(injector, fn)
1506
1830
  : (fn) => fn();
@@ -1552,10 +1876,20 @@ function queryResource(request, options0) {
1552
1876
  const eq = options?.triggerOnSameRequest
1553
1877
  ? undefined
1554
1878
  : (options?.equalRequest ?? createEqualRequest());
1879
+ // Opt-in auto-pausing: `true` reads the ambient Activity boundary (no-op outside
1880
+ // one), a predicate is used directly. Composes with the manual `ctx.paused` path.
1881
+ const pauseOpt = options?.pause ?? false;
1882
+ const externallyPaused = pauseOpt === false
1883
+ ? () => false
1884
+ : typeof pauseOpt === 'function'
1885
+ ? pauseOpt
1886
+ : options?.injector
1887
+ ? runInInjectionContext(options.injector, injectPaused)
1888
+ : injectPaused();
1555
1889
  const requestCtx = { paused: PAUSED };
1556
1890
  const rawResult = computed(() => request(requestCtx), /* @ts-ignore */
1557
1891
  ...(ngDevMode ? [{ debugName: "rawResult" }] : /* istanbul ignore next */ []));
1558
- const paused = computed(() => rawResult() === PAUSED, /* @ts-ignore */
1892
+ const paused = computed(() => rawResult() === PAUSED || externallyPaused(), /* @ts-ignore */
1559
1893
  ...(ngDevMode ? [{ debugName: "paused" }] : /* istanbul ignore next */ []));
1560
1894
  const rawRequest = computed(() => {
1561
1895
  const r = rawResult();
@@ -1567,9 +1901,12 @@ function queryResource(request, options0) {
1567
1901
  return 'offline';
1568
1902
  if (cb.isOpen())
1569
1903
  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())
1904
+ // Both pause sources report 'no-request' here ctx.paused makes rawRequest
1905
+ // undefined, while the external `pause` option still yields a real request, so it
1906
+ // must be checked explicitly. Either way this also stops polling/refresh triggers
1907
+ // (their inactive() guard reads disabledReason), while stableRequest below HOLDS
1908
+ // the last request so the value is kept (no refetch on resume).
1909
+ if (paused() || !rawRequest())
1573
1910
  return 'no-request';
1574
1911
  return null;
1575
1912
  }, /* @ts-ignore */
@@ -1600,8 +1937,10 @@ function queryResource(request, options0) {
1600
1937
  return eq(a, b);
1601
1938
  return a === b;
1602
1939
  } });
1940
+ const varyHeaders = typeof options?.cache === 'object' ? options.cache.varyHeaders : undefined;
1603
1941
  const hashFn = typeof options?.cache === 'object'
1604
- ? (options.cache.hash ?? hashRequest)
1942
+ ? (options.cache.hash ??
1943
+ ((r) => hashRequest(r, varyHeaders)))
1605
1944
  : hashRequest;
1606
1945
  const staleTime = typeof options?.cache === 'object' ? options.cache.staleTime : 0;
1607
1946
  const ttl = typeof options?.cache === 'object' ? options.cache.ttl : undefined;
@@ -1661,22 +2000,30 @@ function queryResource(request, options0) {
1661
2000
  key: entry.key,
1662
2001
  };
1663
2002
  } });
1664
- // A disabled (offline / circuit-open / no-request) or PAUSED resource must not poll.
1665
- resource = refresh(resource, destroyRef, options?.refresh, () => disabledReason() !== null);
2003
+ // A disabled (offline / circuit-open / no-request) or PAUSED resource must not poll
2004
+ // or react to focus/reconnect.
2005
+ resource = refresh(resource, destroyRef, options?.refresh, () => disabledReason() !== null, {
2006
+ injector: options?.injector ?? inject(Injector),
2007
+ visibility: injectPageVisibility(),
2008
+ online: networkAvailable,
2009
+ });
1666
2010
  resource = retryOnError(resource, options?.retry, options?.onError);
1667
2011
  resource = persistResourceValues(resource, options?.keepPrevious, options?.equal);
1668
2012
  const set = (value) => {
1669
2013
  resource.value.set(value);
1670
2014
  const k = untracked(cacheKey);
1671
2015
  if (options?.cache && k)
1672
- cache.store(k, new HttpResponse({
2016
+ cache.store(k,
2017
+ // statusText omitted — deprecated in Angular (HttpResponse defaults it)
2018
+ new HttpResponse({
1673
2019
  body: value,
1674
2020
  status: 200,
1675
- statusText: 'OK',
1676
2021
  }), staleTime, ttl, persist);
1677
2022
  };
1678
2023
  const update = (updater) => {
1679
- set(updater(untracked(resource.value)));
2024
+ // baseline on the COMPOSED value (cache-preferring): the cache entry can be newer
2025
+ // than resource.value (cross-tab sync, another instance's set)
2026
+ set(updater(untracked(value)));
1680
2027
  };
1681
2028
  const value = options?.cache
1682
2029
  ? toWritable(computed(() => cacheEntry()?.value ?? resource.value()), set, update)
@@ -1766,6 +2113,106 @@ function queryResource(request, options0) {
1766
2113
  return ref;
1767
2114
  }
1768
2115
 
2116
+ /**
2117
+ * Creates a paginated HTTP resource over {@link queryResource}: one page request at a
2118
+ * time, accumulated into a `pages` signal — cursor- and offset-based pagination both
2119
+ * fit through `getNextPageParam`. Each page request inherits the full queryResource
2120
+ * feature set (caching per page, retries, circuit breaker, refresh triggers).
2121
+ *
2122
+ * @example
2123
+ * ```ts
2124
+ * const posts = infiniteQueryResource<PostPage, PostPage, number>(
2125
+ * ({ pageParam }) => ({ url: '/api/posts', params: { page: pageParam } }),
2126
+ * {
2127
+ * initialPageParam: 0,
2128
+ * getNextPageParam: (last, all) => (last.items.length < 20 ? null : all.length),
2129
+ * cache: true,
2130
+ * },
2131
+ * );
2132
+ *
2133
+ * // template:
2134
+ * // @for (page of posts.pages(); track $index) { ... }
2135
+ * // <button (click)="posts.fetchNextPage()" [disabled]="!posts.hasNextPage()">More</button>
2136
+ * const flat = computed(() => posts.pages().flatMap((p) => p.items));
2137
+ * ```
2138
+ */
2139
+ function infiniteQueryResource(request, options) {
2140
+ const { initialPageParam, getNextPageParam, ...rest } = options;
2141
+ const injector = options.injector ?? inject(Injector);
2142
+ const pageParam = signal(initialPageParam, /* @ts-ignore */
2143
+ ...(ngDevMode ? [{ debugName: "pageParam" }] : /* istanbul ignore next */ []));
2144
+ // pages keyed by the param that produced them, so a reload of an already-loaded
2145
+ // page REPLACES its slot instead of appending a duplicate
2146
+ const loaded = signal([], /* @ts-ignore */
2147
+ ...(ngDevMode ? [{ debugName: "loaded" }] : /* istanbul ignore next */ []));
2148
+ const resource = queryResource(
2149
+ // forward queryResource's own context so the fn can return ctx.paused —
2150
+ // pausing holds the loaded pages and stops page fetches until unpaused
2151
+ (qctx) => request({ ...qctx, pageParam: pageParam() }), { ...rest, injector });
2152
+ const appendRef = effect(() => {
2153
+ if (resource.status() !== 'resolved')
2154
+ return;
2155
+ const page = resource.value();
2156
+ if (page === undefined)
2157
+ return;
2158
+ untracked(() => {
2159
+ const param = pageParam();
2160
+ loaded.update((list) => {
2161
+ const idx = list.findIndex((e) => Object.is(e.param, param));
2162
+ if (idx >= 0) {
2163
+ const copy = [...list];
2164
+ copy[idx] = { param, page };
2165
+ return copy;
2166
+ }
2167
+ return [...list, { param, page }];
2168
+ });
2169
+ });
2170
+ }, { ...(ngDevMode ? { debugName: "appendRef" } : /* istanbul ignore next */ {}), injector });
2171
+ const pages = computed(() => loaded().map((e) => e.page), /* @ts-ignore */
2172
+ ...(ngDevMode ? [{ debugName: "pages" }] : /* istanbul ignore next */ []));
2173
+ const nextPageParam = computed(() => {
2174
+ const all = pages();
2175
+ if (all.length === 0)
2176
+ return null;
2177
+ return getNextPageParam(all[all.length - 1], all) ?? null;
2178
+ }, /* @ts-ignore */
2179
+ ...(ngDevMode ? [{ debugName: "nextPageParam" }] : /* istanbul ignore next */ []));
2180
+ const hasNextPage = computed(() => nextPageParam() !== null, /* @ts-ignore */
2181
+ ...(ngDevMode ? [{ debugName: "hasNextPage" }] : /* istanbul ignore next */ []));
2182
+ const fetchNextPage = () => {
2183
+ if (untracked(resource.isLoading))
2184
+ return; // one page at a time
2185
+ const next = untracked(nextPageParam);
2186
+ if (next === null)
2187
+ return;
2188
+ pageParam.set(next);
2189
+ };
2190
+ const reset = () => {
2191
+ loaded.set([]);
2192
+ if (Object.is(untracked(pageParam), initialPageParam)) {
2193
+ resource.reload(); // param unchanged — force the refetch
2194
+ }
2195
+ else {
2196
+ pageParam.set(initialPageParam);
2197
+ }
2198
+ };
2199
+ return {
2200
+ pages,
2201
+ hasNextPage,
2202
+ isFetchingNextPage: computed(() => resource.isLoading() && loaded().length > 0),
2203
+ isLoading: resource.isLoading,
2204
+ status: resource.status,
2205
+ error: resource.error,
2206
+ fetchNextPage,
2207
+ reload: () => resource.reload(),
2208
+ reset,
2209
+ destroy: () => {
2210
+ appendRef.destroy();
2211
+ resource.destroy();
2212
+ },
2213
+ };
2214
+ }
2215
+
1769
2216
  function manualQueryResource(request, options) {
1770
2217
  const trigger = signal({ epoch: 0 }, { ...(ngDevMode ? { debugName: "trigger" } : /* istanbul ignore next */ {}), equal: (a, b) => a.epoch === b.epoch });
1771
2218
  const injector = options?.injector ?? inject(Injector);
@@ -1778,6 +2225,12 @@ function manualQueryResource(request, options) {
1778
2225
  return untracked(request);
1779
2226
  }, { ...(ngDevMode ? { debugName: "req" } : /* istanbul ignore next */ {}), equal: () => false });
1780
2227
  const resource = queryResource(req, options);
2228
+ // Shared across trigger() calls: a per-call watcher could observe the PREVIOUS
2229
+ // request's `resolved` status before this trigger's load flips the resource to
2230
+ // loading (effect ordering within a flush is unspecified) and resolve with stale
2231
+ // data; concurrent triggers would also cross-resolve each other's promises.
2232
+ let pending = [];
2233
+ let watcher = null;
1781
2234
  return {
1782
2235
  ...resource,
1783
2236
  trigger: (override, injectorOverride) => {
@@ -1786,15 +2239,41 @@ function manualQueryResource(request, options) {
1786
2239
  override,
1787
2240
  }));
1788
2241
  return new Promise((res, rej) => {
1789
- const watcher = nestedEffect(() => {
2242
+ if (untracked(req) === undefined) {
2243
+ // the request fn produced nothing — no load will ever start, so a watcher
2244
+ // would hang this promise forever
2245
+ rej(new Error('[@mmstack/resource]: trigger() produced no request (the request fn returned undefined)'));
2246
+ return;
2247
+ }
2248
+ pending.push({ res, rej });
2249
+ // an active watcher (concurrent trigger) settles ALL pending promises with
2250
+ // the final result of the latest request — TanStack-style latest-wins
2251
+ if (watcher)
2252
+ return;
2253
+ // only accept a settle AFTER the load for this trigger has been observed —
2254
+ // the pre-trigger status may still be a stale `resolved`/`error`
2255
+ let sawLoading = false;
2256
+ watcher = nestedEffect(() => {
1790
2257
  const status = resource.status();
1791
- if (status === 'resolved') {
1792
- watcher.destroy();
1793
- res(untracked(resource.value));
2258
+ if (status === 'loading' || status === 'reloading') {
2259
+ sawLoading = true;
2260
+ return;
1794
2261
  }
1795
- else if (status === 'error') {
1796
- watcher.destroy();
1797
- rej(untracked(resource.error));
2262
+ if (!sawLoading)
2263
+ return;
2264
+ if (status === 'resolved' || status === 'error') {
2265
+ const settled = pending;
2266
+ pending = [];
2267
+ watcher?.destroy();
2268
+ watcher = null;
2269
+ if (status === 'resolved') {
2270
+ const value = untracked(resource.value);
2271
+ settled.forEach((p) => p.res(value));
2272
+ }
2273
+ else {
2274
+ const err = untracked(resource.error);
2275
+ settled.forEach((p) => p.rej(err));
2276
+ }
1798
2277
  }
1799
2278
  }, { injector: injectorOverride ?? injector });
1800
2279
  });
@@ -1874,7 +2353,8 @@ function mutationResource(request, options0 = {}) {
1874
2353
  };
1875
2354
  // `register` is pulled out (and forced off on the inner query below) so the mutation ref is
1876
2355
  // the only thing registered into the transition scope, not its internal query resource.
1877
- const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, ...rest } = options;
2356
+ const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, invalidates, ...rest } = options;
2357
+ const cache = invalidates ? injectQueryCache(options.injector) : undefined;
1878
2358
  const requestEqual = equalRequest ?? createEqualRequest(equal);
1879
2359
  // A mutation is an imperative command, so `triggerOnSameRequest` means "fire on EVERY mutate(),
1880
2360
  // even with an identical body". By default we dedup an identical value/request while one is in
@@ -1979,8 +2459,19 @@ function mutationResource(request, options0 = {}) {
1979
2459
  .subscribe((result) => {
1980
2460
  if (result.status === 'error')
1981
2461
  onError?.(result.error, ctx);
1982
- else
2462
+ else {
1983
2463
  onSuccess?.(result.value, ctx);
2464
+ if (cache && invalidates) {
2465
+ const mutation = untracked(lastValue);
2466
+ const prefixes = typeof invalidates === 'function'
2467
+ ? invalidates(result.value, (mutation === NULL_VALUE ? undefined : mutation))
2468
+ : invalidates;
2469
+ // auto-keys are `${method}:${url}:...` — a `GET:`-prefixed url prefix hits
2470
+ // the url with any params/subpaths and every varyHeaders variant
2471
+ for (const prefix of prefixes)
2472
+ cache.invalidatePrefix(`GET:${prefix}`);
2473
+ }
2474
+ }
1984
2475
  onSettled?.(ctx);
1985
2476
  ctx = undefined;
1986
2477
  next.set(NULL_VALUE);
@@ -1989,15 +2480,31 @@ function mutationResource(request, options0 = {}) {
1989
2480
  const ref = {
1990
2481
  ...resource,
1991
2482
  destroy: () => {
2483
+ // queue first — a late queue flush must not poke an already-destroyed resource
2484
+ queueRef.destroy();
1992
2485
  statusSub.unsubscribe();
1993
2486
  resource.destroy();
1994
- queueRef.destroy();
1995
2487
  },
1996
2488
  mutate: (value, ictx) => {
1997
2489
  if (shouldQueue) {
1998
2490
  return queue.update((q) => [...q, [value, ictx]]);
1999
2491
  }
2000
2492
  else {
2493
+ // latest-wins: a mutation already in flight gets superseded (its request is
2494
+ // aborted by the request change), so its onSuccess/onError will never fire —
2495
+ // settle its context NOW so optimistic state can be rolled back/cleaned up
2496
+ if (untracked(next) !== NULL_VALUE) {
2497
+ if (isDevMode())
2498
+ 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.');
2499
+ try {
2500
+ onSettled?.(ctx);
2501
+ }
2502
+ catch (settleErr) {
2503
+ if (isDevMode())
2504
+ console.error('[@mmstack/resource]: error thrown in onSettled hook for a superseded mutation', settleErr);
2505
+ }
2506
+ ctx = undefined;
2507
+ }
2001
2508
  try {
2002
2509
  ctx = onMutate?.(value, ictx);
2003
2510
  next.set(value);
@@ -2025,5 +2532,5 @@ function mutationResource(request, options0 = {}) {
2025
2532
  * Generated bundle index. Do not edit.
2026
2533
  */
2027
2534
 
2028
- export { Cache, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
2535
+ export { Cache, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
2029
2536
  //# sourceMappingURL=mmstack-resource.mjs.map