@mmstack/resource 21.4.0 → 21.4.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,27 @@ 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, ...(ngDevMode ? [{ debugName: "hitCount" }] : /* istanbul ignore next */ []));
145
+ missCount = signal(0, ...(ngDevMode ? [{ debugName: "missCount" }] : /* istanbul ignore next */ []));
126
146
  /**
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.
147
+ * Read-only cache statistics for debugging/observability entry count plus
148
+ * request-level hit/miss counters (counted on direct lookups, e.g. the cache
149
+ * interceptor's, not on every reactive signal read). Render it in a debug
150
+ * panel; it intentionally exposes no way to mutate the cache.
151
+ */
152
+ stats = computed(() => ({
153
+ size: this.internal().size,
154
+ hits: this.hitCount(),
155
+ misses: this.missCount(),
156
+ }), ...(ngDevMode ? [{ debugName: "stats" }] : /* istanbul ignore next */ []));
157
+ /**
158
+ * Destroys the cache instance, clearing the cleanup interval and closing the
159
+ * cross-tab channel. Called automatically when the providing injector is destroyed
160
+ * (wired up by `provideQueryCache`); call it manually for caches you construct yourself.
129
161
  */
130
162
  destroy;
131
163
  broadcast = () => {
@@ -142,11 +174,7 @@ class Cache {
142
174
  * @param syncTabs - If provided, the cache will use the options a BroadcastChannel to send updates between tabs.
143
175
  * Defaults to `undefined`, meaning no synchronization across tabs.
144
176
  */
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())) {
177
+ constructor(ttl = ONE_DAY, staleTime = ONE_HOUR, cleanupOpt = DEFAULT_CLEANUP_OPT, syncTabs, db = Promise.resolve(createNoopDB())) {
150
178
  this.ttl = ttl;
151
179
  this.staleTime = staleTime;
152
180
  this.db = db;
@@ -156,10 +184,12 @@ class Cache {
156
184
  };
157
185
  if (this.cleanupOpt.maxSize <= 0)
158
186
  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);
187
+ // a non-finite checkInterval disables the sweeper entirely (used by the shared NoopCache)
188
+ const cleanupInterval = Number.isFinite(this.cleanupOpt.checkInterval)
189
+ ? setInterval(() => {
190
+ this.cleanup();
191
+ }, this.cleanupOpt.checkInterval)
192
+ : undefined;
163
193
  let destroySyncTabs = () => {
164
194
  // noop
165
195
  };
@@ -193,13 +223,11 @@ class Cache {
193
223
  const value = syncTabs.deserialize(msg.entry.value);
194
224
  if (value === null)
195
225
  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.
226
+ // Last-write-wins by `updated` timestamp.
199
227
  const existing = untracked(this.internal).get(msg.entry.key);
200
228
  if (existing && existing.updated >= msg.entry.updated)
201
229
  return;
202
- this.storeInternal(msg.entry.key, value, msg.entry.stale - msg.entry.updated, msg.entry.expiresAt - msg.entry.updated, true, false);
230
+ this.restoreInternal({ ...msg.entry, value });
203
231
  }
204
232
  else if (msg.action === 'invalidate') {
205
233
  this.invalidateInternal(msg.entry.key, true);
@@ -214,7 +242,8 @@ class Cache {
214
242
  if (destroyed)
215
243
  return;
216
244
  destroyed = true;
217
- clearInterval(cleanupInterval);
245
+ if (cleanupInterval !== undefined)
246
+ clearInterval(cleanupInterval);
218
247
  destroySyncTabs();
219
248
  };
220
249
  this.db
@@ -226,22 +255,19 @@ class Cache {
226
255
  .then((entries) => {
227
256
  if (destroyed)
228
257
  return;
229
- // load entries into the cache
230
258
  const current = untracked(this.internal);
231
259
  entries.forEach((entry) => {
232
260
  if (current.has(entry.key))
233
261
  return;
234
- this.storeInternal(entry.key, entry.value, entry.stale - entry.updated, entry.expiresAt - entry.updated, true);
262
+ // a key invalidated while hydration was in flight must stay dead
263
+ if (this.hydrationTombstones.has(entry.key))
264
+ return;
265
+ this.restoreInternal(entry);
235
266
  });
267
+ this.hydrated = true;
268
+ this.hydrationTombstones.clear();
236
269
  });
237
270
  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
271
  }
246
272
  /** @internal */
247
273
  getInternal(key) {
@@ -254,22 +280,45 @@ class Cache {
254
280
  const now = Date.now();
255
281
  if (!found || found.expiresAt <= now)
256
282
  return null;
257
- found.useCount++;
258
283
  return {
259
284
  ...found,
260
285
  isStale: found.stale <= now,
261
286
  };
287
+ }, {
288
+ equal: (a, b) => a === b ||
289
+ (!!a &&
290
+ !!b &&
291
+ a.key === b.key &&
292
+ a.value === b.value &&
293
+ a.updated === b.updated &&
294
+ a.isStale === b.isStale),
262
295
  });
263
296
  }
297
+ /** @internal Imperative access bookkeeping for LRU eviction. */
298
+ touch(entry) {
299
+ entry.lastAccessed = Date.now();
300
+ entry.useCount++;
301
+ }
264
302
  /**
265
- * Retrieves a cache entry without affecting its usage count (for LRU). This is primarily
266
- * for internal use or debugging.
303
+ * Retrieves a cache entry directly (non-reactively), updating its access bookkeeping
304
+ * for LRU eviction.
267
305
  * @internal
268
306
  * @param key - The key of the entry to retrieve.
269
307
  * @returns The cache entry, or `null` if not found or expired.
270
308
  */
271
309
  getUntracked(key) {
272
- return untracked(this.getInternal(() => key));
310
+ const found = untracked(this.internal).get(key);
311
+ const now = Date.now();
312
+ if (!found || found.expiresAt <= now) {
313
+ this.missCount.update((c) => c + 1);
314
+ return null;
315
+ }
316
+ this.touch(found);
317
+ this.hitCount.update((c) => c + 1);
318
+ return {
319
+ ...found,
320
+ isStale: found.stale <= now,
321
+ };
273
322
  }
274
323
  /**
275
324
  * Retrieves a cache entry as a signal.
@@ -295,38 +344,65 @@ class Cache {
295
344
  /**
296
345
  * Stores a value in the cache.
297
346
  *
347
+ * NOTE: cached values are shared by reference across all consumers (current and
348
+ * future cache hits, persistence, cross-tab sync) — do not mutate a value after
349
+ * storing it or after reading it from the cache.
350
+ *
298
351
  * @param key - The key under which to store the value.
299
352
  * @param value - The value to store.
300
353
  * @param staleTime - (Optional) The stale time for this entry, in milliseconds. Overrides the default `staleTime`.
301
354
  * @param ttl - (Optional) The TTL for this entry, in milliseconds. Overrides the default `ttl`.
355
+ * @param persist - (Optional) Whether to also write the entry to the persistence layer (IndexedDB). Defaults to `false`.
302
356
  */
303
357
  store(key, value, staleTime = this.staleTime, ttl = this.ttl, persist = false) {
304
358
  this.storeInternal(key, value, staleTime, ttl, false, persist);
305
359
  }
306
360
  storeInternal(key, value, staleTime = this.staleTime, ttl = this.ttl, fromSync = false, persist = false) {
307
- const entry = this.getUntracked(key);
308
- if (entry) {
309
- clearTimeout(entry.timeout); // stop invalidation
310
- }
311
- const prevCount = entry?.useCount ?? 0;
361
+ const entry = untracked(this.internal).get(key);
312
362
  // ttl cannot be less than staleTime
313
363
  if (ttl < staleTime)
314
364
  staleTime = ttl;
315
365
  const now = Date.now();
316
- const next = {
366
+ this.setEntry({
317
367
  value,
318
368
  created: entry?.created ?? now,
319
369
  updated: now,
320
- useCount: prevCount + 1,
370
+ useCount: (entry?.useCount ?? 0) + 1,
371
+ lastAccessed: now,
321
372
  stale: now + staleTime,
322
373
  expiresAt: now + ttl,
323
374
  key,
324
- };
375
+ }, fromSync, persist);
376
+ }
377
+ /**
378
+ * @internal
379
+ * Inserts an entry that already carries ABSOLUTE timestamps — hydration from the
380
+ * persistence layer and cross-tab sync messages. Never re-anchors freshness to
381
+ * `Date.now()`, never persists, never broadcasts.
382
+ */
383
+ restoreInternal(entry) {
384
+ this.setEntry({
385
+ ...entry,
386
+ // rows persisted by older versions may lack the field
387
+ lastAccessed: entry.lastAccessed ?? entry.updated,
388
+ }, true, false);
389
+ }
390
+ /** @internal Shared writer: arms the expiry timer only within the safe delay range. */
391
+ setEntry(next, fromSync, persist) {
392
+ const existing = untracked(this.internal).get(next.key);
393
+ if (existing)
394
+ clearTimeout(existing.timeout); // stop the previous invalidation
395
+ const remaining = next.expiresAt - Date.now();
396
+ // already expired (clock skew on a synced/restored entry) — don't insert
397
+ if (remaining <= 0)
398
+ return;
399
+ // Infinity (immutable) or > 2^31-1 would coerce to an IMMEDIATE timeout — such
400
+ // entries get no timer and rely on lazy expiry + the periodic sweep instead
401
+ const timeout = Number.isFinite(remaining) && remaining <= MAX_TIMER_DELAY
402
+ ? setTimeout(() => this.invalidate(next.key), remaining)
403
+ : undefined;
325
404
  this.internal.mutate((map) => {
326
- map.set(key, {
327
- ...next,
328
- timeout: setTimeout(() => this.invalidate(key), ttl),
329
- });
405
+ map.set(next.key, { ...next, timeout });
330
406
  return map;
331
407
  });
332
408
  if (!fromSync) {
@@ -372,32 +448,55 @@ class Cache {
372
448
  return keys.length;
373
449
  }
374
450
  invalidateInternal(key, fromSync = false) {
375
- const entry = this.getUntracked(key);
376
- if (!entry)
377
- return;
378
- clearTimeout(entry.timeout);
379
- this.internal.mutate((map) => {
380
- map.delete(key);
381
- return map;
382
- });
451
+ // a key invalidated before async hydration completes must not be resurrected by it
452
+ if (!this.hydrated)
453
+ this.hydrationTombstones.add(key);
454
+ const entry = untracked(this.internal).get(key);
455
+ if (entry) {
456
+ clearTimeout(entry.timeout);
457
+ this.internal.mutate((map) => {
458
+ map.delete(key);
459
+ return map;
460
+ });
461
+ }
383
462
  if (!fromSync) {
384
463
  this.db.then((db) => db.remove(key));
385
464
  this.broadcast({ action: 'invalidate', entry: { key } });
386
465
  }
387
466
  }
388
- /** @internal */
467
+ /**
468
+ * Removes EVERY entry — memory, persisted rows, and (via broadcast) other tabs.
469
+ * Call on logout/auth changes so no prior user's responses survive.
470
+ */
471
+ clear() {
472
+ for (const key of Array.from(untracked(this.internal).keys())) {
473
+ this.invalidateInternal(key);
474
+ }
475
+ }
476
+ /** @internal Drops expired entries, then enforces `maxSize` by the configured strategy. */
389
477
  cleanup() {
478
+ const now = Date.now();
479
+ // expired entries first — their timers may never have fired (throttled background
480
+ // tabs, or timer-less long-TTL entries)
481
+ const expired = Array.from(untracked(this.internal).entries()).filter(([, e]) => e.expiresAt <= now);
482
+ if (expired.length) {
483
+ expired.forEach(([, e]) => clearTimeout(e.timeout));
484
+ this.internal.mutate((map) => {
485
+ expired.forEach(([key]) => map.delete(key));
486
+ return map;
487
+ });
488
+ }
390
489
  if (untracked(this.internal).size <= this.cleanupOpt.maxSize)
391
490
  return;
392
491
  const sorted = Array.from(untracked(this.internal).entries()).toSorted((a, b) => {
393
492
  if (this.cleanupOpt.type === 'lru') {
394
- return a[1].useCount - b[1].useCount; // least used first
493
+ return a[1].lastAccessed - b[1].lastAccessed; // least recently accessed first
395
494
  }
396
495
  else {
397
496
  return a[1].created - b[1].created; // oldest first
398
497
  }
399
498
  });
400
- const keepCount = Math.floor(this.cleanupOpt.maxSize / 2);
499
+ const keepCount = Math.max(1, Math.floor(this.cleanupOpt.maxSize / 2));
401
500
  const removed = sorted.slice(0, sorted.length - keepCount);
402
501
  const keep = sorted.slice(removed.length, sorted.length);
403
502
  removed.forEach(([, e]) => {
@@ -442,7 +541,8 @@ function provideQueryCache(opt) {
442
541
  return JSON.stringify({
443
542
  body: value.body,
444
543
  status: value.status,
445
- statusText: value.statusText,
544
+ // statusText intentionally omitted: deprecated in Angular, meaningless under
545
+ // HTTP/2+ (HttpResponse defaults it to 'OK' on reconstruction)
446
546
  headers: headerKeys.length > 0 ? headersRecord : undefined,
447
547
  url: value.url,
448
548
  });
@@ -458,7 +558,6 @@ function provideQueryCache(opt) {
458
558
  return new HttpResponse({
459
559
  body: parsed.body,
460
560
  status: parsed.status,
461
- statusText: parsed.statusText,
462
561
  headers: headers,
463
562
  url: parsed.url,
464
563
  });
@@ -469,49 +568,72 @@ function provideQueryCache(opt) {
469
568
  return null;
470
569
  }
471
570
  };
472
- const syncTabsOpt = opt?.syncTabs
473
- ? {
474
- id: 'mmstack-query-cache-sync',
475
- serialize,
476
- deserialize,
477
- }
478
- : undefined;
479
- const db = opt?.persist === false
480
- ? undefined
481
- : createSingleStoreDB('mmstack-query-cache-db', (version) => `query-store_v${version}`, opt?.version).then((db) => {
482
- return {
483
- getAll: () => {
484
- return db.getAll().then((entries) => {
485
- return entries
486
- .map((entry) => {
487
- const value = deserialize(entry.value);
488
- if (value === null)
489
- return null;
490
- return {
491
- ...entry,
492
- value,
493
- };
494
- })
495
- .filter((e) => e !== null);
496
- });
497
- },
498
- store: (entry) => {
499
- return db.store({ ...entry, value: serialize(entry.value) });
500
- },
501
- remove: db.remove,
502
- };
503
- });
571
+ // version-suffixed so two deploys with incompatible schemas in adjacent tabs don't
572
+ // push entries into each other's caches (the `version` option only fences IndexedDB)
573
+ const syncChannelId = `mmstack-query-cache-sync_v${opt?.version ?? 1}`;
504
574
  return {
505
575
  provide: CLIENT_CACHE_TOKEN,
506
- useValue: new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup, syncTabsOpt, db),
576
+ useFactory: () => {
577
+ const onServer = inject(PLATFORM_ID) === 'server';
578
+ // no IndexedDB / BroadcastChannel on the server — each request gets an
579
+ // isolated, request-lived, memory-only cache
580
+ const syncTabsOpt = !onServer && opt?.syncTabs
581
+ ? {
582
+ id: syncChannelId,
583
+ serialize,
584
+ deserialize,
585
+ }
586
+ : undefined;
587
+ const db = onServer || opt?.persist === false
588
+ ? undefined
589
+ : createSingleStoreDB('mmstack-query-cache-db', (version) => `query-store_v${version}`, opt?.version).then((db) => {
590
+ return {
591
+ getAll: () => {
592
+ return db.getAll().then((entries) => {
593
+ return entries
594
+ .map((entry) => {
595
+ const value = deserialize(entry.value);
596
+ if (value === null)
597
+ return null;
598
+ return {
599
+ ...entry,
600
+ value,
601
+ };
602
+ })
603
+ .filter((e) => e !== null);
604
+ });
605
+ },
606
+ store: (entry) => {
607
+ return db.store({ ...entry, value: serialize(entry.value) });
608
+ },
609
+ remove: db.remove,
610
+ };
611
+ });
612
+ const cache = new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup, syncTabsOpt, db);
613
+ // release the sweep interval / channel with the providing injector
614
+ inject(DestroyRef, { optional: true })?.onDestroy(() => cache.destroy());
615
+ return cache;
616
+ },
507
617
  };
508
618
  }
509
619
  class NoopCache extends Cache {
620
+ constructor() {
621
+ // Infinity checkInterval → no sweep interval is ever armed, so the shared
622
+ // instance below never pins a timer
623
+ super(undefined, undefined, {
624
+ type: 'lru',
625
+ maxSize: 200,
626
+ checkInterval: Infinity,
627
+ });
628
+ }
510
629
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
511
630
  store(_, __, ___ = super.staleTime, ____ = super.ttl) {
512
631
  // noop
513
632
  }
514
633
  }
634
+ // one shared instance — minting a NoopCache per injectQueryCache() miss would leak
635
+ // an instance (and previously an interval) on every prod call without a provider
636
+ let NOOP_CACHE;
515
637
  /**
516
638
  * Injects the `QueryCache` instance that is used within queryResource.
517
639
  * Allows for direct modification of cached data, but is mostly meant for internal use.
@@ -546,10 +668,21 @@ function injectQueryCache(injector) {
546
668
  if (isDevMode())
547
669
  throw new Error('Cache not provided, please add provideQueryCache() to providers array');
548
670
  else
549
- return new NoopCache();
671
+ return (NOOP_CACHE ??= new NoopCache());
550
672
  }
551
673
  return cache;
552
674
  }
675
+ /**
676
+ * Injects the cache statistics, including the current size of the cache and the number of hits and misses.
677
+ *
678
+ * @param injector - (Optional) The injector to use. If not provided, the current
679
+ * injection context is used.
680
+ * @returns A signal containing the cache statistics.
681
+ */
682
+ function injectCacheStats(injector) {
683
+ const cache = injectQueryCache(injector);
684
+ return cache.stats;
685
+ }
553
686
 
554
687
  /**
555
688
  * Returns `true` for any object-like value whose own enumerable keys should
@@ -653,6 +786,76 @@ function hash(...args) {
653
786
  return hashKey(args);
654
787
  }
655
788
 
789
+ /**
790
+ * @internal
791
+ * One-way ~64-bit digest from two independent FNV-1a passes. Used for header VALUES in
792
+ * cache keys: keys are persisted (IndexedDB) and broadcast cross-tab, so raw values
793
+ * (auth tokens!) must never appear in them. A single 32-bit digest's 2^-32 collision
794
+ * chance is too thin at a security boundary — two colliding tokens would serve one
795
+ * user's cached data under another user's key; 64 bits puts collisions out of reach.
796
+ * High-entropy secrets are not recoverable from the digest.
797
+ */
798
+ function digestHeaderValue(value) {
799
+ let h1 = 0x811c9dc5; // FNV-1a offset basis
800
+ let h2 = 0xcbf29ce4; // independent second pass
801
+ for (let i = 0; i < value.length; i++) {
802
+ const c = value.charCodeAt(i);
803
+ h1 = Math.imul(h1 ^ c, 0x01000193); // FNV prime
804
+ h2 = Math.imul(h2 ^ c, 0x01000197); // distinct odd multiplier
805
+ }
806
+ return ((h1 >>> 0).toString(16).padStart(8, '0') +
807
+ (h2 >>> 0).toString(16).padStart(8, '0'));
808
+ }
809
+ function readHeader(headers, name) {
810
+ if (!headers)
811
+ return null;
812
+ if (headers instanceof HttpHeaders) {
813
+ const all = headers.getAll(name);
814
+ return all && all.length ? all.join(',') : null;
815
+ }
816
+ // record form — header names are case-insensitive
817
+ const lower = name.toLowerCase();
818
+ for (const key of Object.keys(headers)) {
819
+ if (key.toLowerCase() !== lower)
820
+ continue;
821
+ const value = headers[key];
822
+ if (value == null)
823
+ return null;
824
+ return Array.isArray(value) ? value.join(',') : String(value);
825
+ }
826
+ return null;
827
+ }
828
+ /**
829
+ * Content-negotiation headers whose values are low-entropy and non-identifying —
830
+ * embedded (URI-encoded) raw, keeping keys human-readable and skipping the digest.
831
+ * Anything NOT on this list (Authorization, api keys, tenant/x-* headers — we can't
832
+ * know what they carry) is one-way digested instead.
833
+ */
834
+ const SAFE_RAW_HEADERS = new Set([
835
+ 'accept',
836
+ 'accept-language',
837
+ 'content-language',
838
+ 'content-type',
839
+ ]);
840
+ function normalizeVaryHeaders(headers, names) {
841
+ return names
842
+ .map((n) => n.toLowerCase())
843
+ .toSorted()
844
+ .map((name) => {
845
+ if (isDevMode() && (name === 'cookie' || name === 'set-cookie')) {
846
+ 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.`);
847
+ }
848
+ const value = readHeader(headers, name);
849
+ if (value === null)
850
+ return `${name}=`;
851
+ // known-safe values raw (readable, cheap); everything else digested, NEVER raw —
852
+ // keys are persisted to IndexedDB and broadcast across tabs
853
+ return SAFE_RAW_HEADERS.has(name)
854
+ ? `${name}=${encodeURIComponent(value)}`
855
+ : `${name}=${digestHeaderValue(value)}`;
856
+ })
857
+ .join('&');
858
+ }
656
859
  function normalizeParams(params) {
657
860
  const p = params instanceof HttpParams ? params : new HttpParams({ fromObject: params });
658
861
  return p
@@ -697,20 +900,48 @@ function hashBody(body) {
697
900
  * Builds a stable cache/dedupe key from an HTTP request shape (accepts both
698
901
  * `HttpRequest` and `HttpResourceRequest`).
699
902
  *
700
- * Key composition: `${method}:${url}:${responseType}[:${params}][:${body}]`
903
+ * Key composition: `${method}:${url}:${responseType}[:${params}][:${body}][:${vary}]`
701
904
  * - `method` defaults to `'GET'`, `responseType` to `'json'` (Angular defaults).
702
905
  * - Query params are sorted alphabetically and URL-encoded for stability.
703
906
  * - Body hashing handles `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`
704
907
  * and typed arrays explicitly; everything else flows through key-sorted
705
908
  * `JSON.stringify` via `hash()`.
909
+ * - `varyHeaders` (opt-in) mixes the named request headers into the key so responses
910
+ * that differ per header (e.g. `Authorization` → per-user, `Accept-Language`) get
911
+ * separate entries. Known-safe content-negotiation headers (`Accept`,
912
+ * `Accept-Language`, `Content-Language`, `Content-Type`) embed their value raw for
913
+ * readable keys; all other header VALUES are one-way digested, never embedded raw —
914
+ * keys are persisted to IndexedDB and broadcast across tabs.
706
915
  */
707
- function hashRequest(req) {
916
+ function hashRequest(req, varyHeaders) {
708
917
  const method = req.method ?? 'GET';
709
918
  const responseType = req.responseType ?? 'json';
710
919
  const base = `${method}:${req.url}:${responseType}`;
711
920
  const params = req.params ? `:${normalizeParams(req.params)}` : '';
712
921
  const body = req.body != null ? `:${hashBody(req.body)}` : '';
713
- return base + params + body;
922
+ const vary = varyHeaders?.length
923
+ ? `:vary(${normalizeVaryHeaders(req.headers, varyHeaders)})`
924
+ : '';
925
+ return base + params + body + vary;
926
+ }
927
+
928
+ /**
929
+ * @internal
930
+ * Single-flight sharing: if a pending observable is already registered under `key`,
931
+ * return it; otherwise create one, share it (replaying the latest event to late
932
+ * subscribers), and deregister it on teardown/settle.
933
+ *
934
+ * Used by both the dedupe interceptor (keyed by full request hash, app-wide) and the
935
+ * cache interceptor (keyed by the CACHE key, guarding the miss/stale-revalidation path)
936
+ * — same mechanism, different keying/scope, so it lives here exactly once.
937
+ */
938
+ function sharePending(pending, key, create) {
939
+ const existing = pending.get(key);
940
+ if (existing)
941
+ return existing;
942
+ const shared = create().pipe(finalize(() => pending.delete(key)), shareReplay({ bufferSize: 1, refCount: true }));
943
+ pending.set(key, shared);
944
+ return shared;
714
945
  }
715
946
 
716
947
  const CACHE_CONTEXT = new HttpContextToken(() => ({
@@ -730,6 +961,7 @@ function parseCacheControlHeader(req) {
730
961
  noCache: false,
731
962
  mustRevalidate: false,
732
963
  immutable: false,
964
+ isPrivate: false,
733
965
  maxAge: null,
734
966
  staleWhileRevalidate: null,
735
967
  };
@@ -753,6 +985,9 @@ function parseCacheControlHeader(req) {
753
985
  case 'immutable':
754
986
  directives.immutable = true;
755
987
  break;
988
+ case 'private':
989
+ directives.isPrivate = true;
990
+ break;
756
991
  case 'max-age': {
757
992
  if (!value)
758
993
  break;
@@ -761,7 +996,7 @@ function parseCacheControlHeader(req) {
761
996
  directives.maxAge = parsedValue;
762
997
  break;
763
998
  }
764
- case 's-max-age': {
999
+ case 's-maxage': {
765
1000
  if (!value)
766
1001
  break;
767
1002
  const parsedValue = parseInt(value, 10);
@@ -779,7 +1014,7 @@ function parseCacheControlHeader(req) {
779
1014
  }
780
1015
  }
781
1016
  }
782
- // s-max-age takes precedence over max-age
1017
+ // s-maxage takes precedence over max-age
783
1018
  if (sMaxAge !== null)
784
1019
  directives.maxAge = sMaxAge;
785
1020
  // if no store nothing else is relevant
@@ -789,6 +1024,7 @@ function parseCacheControlHeader(req) {
789
1024
  noCache: false,
790
1025
  mustRevalidate: false,
791
1026
  immutable: false,
1027
+ isPrivate: directives.isPrivate,
792
1028
  maxAge: null,
793
1029
  staleWhileRevalidate: null,
794
1030
  };
@@ -808,14 +1044,32 @@ function resolveTimings(cacheControl, optStaleTime, optTTL) {
808
1044
  staleTime: Infinity,
809
1045
  ttl: Infinity,
810
1046
  };
811
- if (cacheControl.maxAge !== null)
812
- ttl = cacheControl.maxAge * 1000;
813
- if (cacheControl.staleWhileRevalidate !== null)
814
- staleTime = cacheControl.staleWhileRevalidate * 1000;
815
- // if no-cache is set, we must always revalidate
1047
+ if (cacheControl.maxAge !== null) {
1048
+ staleTime = cacheControl.maxAge * 1000;
1049
+ if (cacheControl.staleWhileRevalidate !== null) {
1050
+ ttl = staleTime + cacheControl.staleWhileRevalidate * 1000;
1051
+ }
1052
+ else if (ttl !== undefined) {
1053
+ // a configured total lifetime must never undercut the server's fresh window
1054
+ ttl = Math.max(ttl, staleTime);
1055
+ }
1056
+ // no swr + no configured ttl → leave undefined so the cache's default ttl applies
1057
+ // (the entry stays resident past max-age for ETag revalidation)
1058
+ }
1059
+ else if (cacheControl.staleWhileRevalidate !== null) {
1060
+ // swr without max-age: stale immediately, revalidatable for the window
1061
+ staleTime = 0;
1062
+ ttl = cacheControl.staleWhileRevalidate * 1000;
1063
+ }
1064
+ // if no-cache is set, we must always revalidate (the entry stays usable for conditional requests until ttl)
816
1065
  if (cacheControl.noCache || cacheControl.mustRevalidate)
817
1066
  staleTime = 0;
818
- if (ttl !== undefined && staleTime !== undefined && ttl < staleTime) {
1067
+ // option-only path (no server freshness): a misconfigured ttl < staleTime clamps the
1068
+ // fresh window down, mirroring the cache's own internal clamp
1069
+ if (cacheControl.maxAge === null &&
1070
+ ttl !== undefined &&
1071
+ staleTime !== undefined &&
1072
+ ttl < staleTime) {
819
1073
  staleTime = ttl;
820
1074
  }
821
1075
  return { staleTime, ttl };
@@ -829,6 +1083,10 @@ function resolveTimings(cacheControl, optStaleTime, optTTL) {
829
1083
  * is made to the server, and the response is cached according to the configured TTL and staleness.
830
1084
  * The interceptor also respects `Cache-Control` headers from the server.
831
1085
  *
1086
+ * Cache-enabled requests are single-flighted per cache key: N concurrent consumers of
1087
+ * the same missing/stale entry share ONE network request. Non-cached requests are not
1088
+ * touched — pair with `createDedupeRequestsInterceptor` to coalesce those as well.
1089
+ *
832
1090
  * @param allowedMethods - An array of HTTP methods for which caching should be enabled.
833
1091
  * Defaults to `['GET', 'HEAD', 'OPTIONS']`.
834
1092
  *
@@ -849,7 +1107,10 @@ function resolveTimings(cacheControl, optStaleTime, optTTL) {
849
1107
  */
850
1108
  function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
851
1109
  const CACHE_METHODS = new Set(allowedMethods);
1110
+ const inFlight = new Map();
852
1111
  return (req, next) => {
1112
+ if (inject(PLATFORM_ID) === 'server')
1113
+ return next(req);
853
1114
  const cache = injectQueryCache();
854
1115
  if (!CACHE_METHODS.has(req.method))
855
1116
  return next(req);
@@ -862,60 +1123,78 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
862
1123
  if (entry && !entry.isStale)
863
1124
  return of(entry.value);
864
1125
  // resource itself handles case of showing stale data...the request must process as this will "refresh said data"
865
- const eTag = entry?.value.headers.get('ETag');
866
- const lastModified = entry?.value.headers.get('Last-Modified');
867
- if (eTag) {
868
- req = req.clone({ setHeaders: { 'If-None-Match': eTag } });
869
- }
870
- if (lastModified) {
871
- req = req.clone({ setHeaders: { 'If-Modified-Since': lastModified } });
872
- }
873
- if (opt.bustBrowserCache) {
874
- req = req.clone({
875
- setParams: { _cb: Date.now().toString() },
876
- });
877
- }
878
- return next(req).pipe(tap((event) => {
879
- if (!(event instanceof HttpResponse))
880
- return;
881
- if (event.ok) {
882
- const cacheControl = parseCacheControlHeader(event);
883
- if (cacheControl.noStore && !opt.ignoreCacheControl)
884
- return;
885
- const { staleTime, ttl } = opt.ignoreCacheControl
886
- ? opt
887
- : resolveTimings(cacheControl, opt.staleTime, opt.ttl);
888
- if (opt.ttl === 0)
889
- return; // no point
890
- const parsedResponse = opt.parse
891
- ? new HttpResponse({
892
- body: opt.parse(event.body),
893
- headers: event.headers,
894
- status: event.status,
895
- statusText: event.statusText,
896
- url: event.url ?? undefined,
897
- })
898
- : event;
899
- cache.store(key, parsedResponse, staleTime, ttl, opt.persist);
900
- return;
1126
+ return sharePending(inFlight, key, () => {
1127
+ const eTag = entry?.value.headers.get('ETag');
1128
+ const lastModified = entry?.value.headers.get('Last-Modified');
1129
+ if (eTag) {
1130
+ req = req.clone({ setHeaders: { 'If-None-Match': eTag } });
1131
+ }
1132
+ if (lastModified) {
1133
+ req = req.clone({ setHeaders: { 'If-Modified-Since': lastModified } });
901
1134
  }
902
- // 304 → server confirmed our cached entry is still valid. Re-stamp the
903
- // existing entry so subsequent reads within the new freshness window
904
- // don't trigger another revalidation round-trip.
905
- if (event.status === 304 && entry) {
906
- const cacheControl = parseCacheControlHeader(event);
907
- const { staleTime, ttl } = opt.ignoreCacheControl
908
- ? opt
909
- : resolveTimings(cacheControl, opt.staleTime, opt.ttl);
910
- cache.store(key, entry.value, staleTime, ttl, opt.persist);
1135
+ if (opt.bustBrowserCache) {
1136
+ req = req.clone({
1137
+ setParams: { _cb: Date.now().toString() },
1138
+ });
911
1139
  }
912
- }), map((event) => {
913
- // handle 304 responses due to eTag/last-modified
914
- if (event instanceof HttpResponse && event.status === 304 && entry) {
915
- return entry.value;
1140
+ // non-JSON bodies (blob/arraybuffer) cannot survive the JSON persistence layer
1141
+ const persistable = req.responseType === 'json';
1142
+ if (opt.persist && !persistable && isDevMode()) {
1143
+ console.warn(`[@mmstack/resource]: persist was requested for a '${req.responseType}' response — such bodies don't survive JSON serialization, persisting skipped.`);
916
1144
  }
917
- return event;
918
- }));
1145
+ return next(req).pipe(tap((event) => {
1146
+ if (!(event instanceof HttpResponse))
1147
+ return;
1148
+ if (event.ok) {
1149
+ const cacheControl = parseCacheControlHeader(event);
1150
+ if (cacheControl.noStore && !opt.ignoreCacheControl)
1151
+ return;
1152
+ const { staleTime, ttl } = opt.ignoreCacheControl
1153
+ ? opt
1154
+ : resolveTimings(cacheControl, opt.staleTime, opt.ttl);
1155
+ if (ttl === 0)
1156
+ return; // no point
1157
+ // `Cache-Control: private` → fine to keep in memory, never on disk
1158
+ const persist = (opt.persist ?? false) &&
1159
+ persistable &&
1160
+ (opt.ignoreCacheControl || !cacheControl.isPrivate);
1161
+ const parsedResponse = opt.parse
1162
+ ? // statusText omitted — deprecated in Angular (HttpResponse defaults it)
1163
+ new HttpResponse({
1164
+ body: opt.parse(event.body),
1165
+ headers: event.headers,
1166
+ status: event.status,
1167
+ url: event.url ?? undefined,
1168
+ })
1169
+ : event;
1170
+ cache.store(key, parsedResponse, staleTime, ttl, persist);
1171
+ return;
1172
+ }
1173
+ // 304 → server confirmed our cached entry is still valid. Re-stamp the
1174
+ // existing entry so subsequent reads within the new freshness window
1175
+ // don't trigger another revalidation round-trip.
1176
+ if (event.status === 304 && entry) {
1177
+ // ...unless the key was invalidated while this conditional request was in
1178
+ // flight (e.g. by a mutation) — re-storing would resurrect deleted data
1179
+ if (!cache.getUntracked(key))
1180
+ return;
1181
+ const cacheControl = parseCacheControlHeader(event);
1182
+ const { staleTime, ttl } = opt.ignoreCacheControl
1183
+ ? opt
1184
+ : resolveTimings(cacheControl, opt.staleTime, opt.ttl);
1185
+ const persist = (opt.persist ?? false) &&
1186
+ persistable &&
1187
+ (opt.ignoreCacheControl || !cacheControl.isPrivate);
1188
+ cache.store(key, entry.value, staleTime, ttl, persist);
1189
+ }
1190
+ }), map((event) => {
1191
+ // handle 304 responses due to eTag/last-modified
1192
+ if (event instanceof HttpResponse && event.status === 304 && entry) {
1193
+ return entry.value;
1194
+ }
1195
+ return event;
1196
+ }));
1197
+ });
919
1198
  };
920
1199
  }
921
1200
 
@@ -1137,6 +1416,12 @@ function noDedupe(ctx = new HttpContext()) {
1137
1416
  * only the first request will be sent to the server. Subsequent requests will
1138
1417
  * receive the response from the first request.
1139
1418
  *
1419
+ * Relationship to `createCacheInterceptor`: the cache interceptor has built-in
1420
+ * single-flight for CACHE-ENABLED requests (keyed by the cache key). This interceptor
1421
+ * covers everything the cache doesn't see — non-cached resources, plain HttpClient
1422
+ * calls, DELETEs — keyed by the request hash. Installing both is the recommended
1423
+ * setup; where they overlap, this one degrades to a no-op passthrough.
1424
+ *
1140
1425
  * @param allowed - An array of HTTP methods for which deduplication should be enabled.
1141
1426
  * Defaults to `['GET', 'DELETE', 'HEAD', 'OPTIONS']`.
1142
1427
  * @param keyFn - Optional function to compute the dedupe key from a request.
@@ -1171,13 +1456,7 @@ function createDedupeRequestsInterceptor(allowed = ['GET', 'DELETE', 'HEAD', 'OP
1171
1456
  return (req, next) => {
1172
1457
  if (!DEDUPE_METHODS.has(req.method) || req.context.get(NO_DEDUPE))
1173
1458
  return next(req);
1174
- const key = keyFn(req);
1175
- const found = inFlight.get(key);
1176
- if (found)
1177
- return found;
1178
- const request = next(req).pipe(finalize(() => inFlight.delete(key)), shareReplay({ bufferSize: 1, refCount: true }));
1179
- inFlight.set(key, request);
1180
- return request;
1459
+ return sharePending(inFlight, keyFn(req), () => next(req));
1181
1460
  };
1182
1461
  }
1183
1462
 
@@ -1361,24 +1640,61 @@ function persistResourceValues(resource, shouldPersist = false, equal) {
1361
1640
  };
1362
1641
  }
1363
1642
 
1364
- // refresh resource every n miliseconds or don't refresh if undefined provided. 0 also excluded, due to it not being a valid usecase.
1365
- function refresh(resource, destroyRef, refresh, inactive) {
1366
- if (!refresh)
1643
+ // refresh resource every n milliseconds and/or on visibility/reconnect transitions.
1644
+ function refresh(resource, destroyRef, opt, inactive, triggers) {
1645
+ const normalized = typeof opt === 'number' ? { interval: opt } : (opt ?? {});
1646
+ const { interval: ms, onFocus = false, onReconnect = false, } = normalized;
1647
+ const hasInterval = !!ms; // 0 excluded — not a valid polling cadence
1648
+ const hasTriggerEffects = !!triggers && (onFocus || onReconnect);
1649
+ if (!hasInterval && !hasTriggerEffects)
1367
1650
  return resource; // no refresh requested
1368
1651
  const tick = () => {
1369
1652
  if (inactive?.())
1370
- return; // disabled / paused → skip the poll
1653
+ return; // disabled / paused → skip
1371
1654
  resource.reload();
1372
1655
  };
1656
+ const effectRefs = [];
1657
+ if (triggers && onFocus) {
1658
+ const vis = triggers.visibility;
1659
+ let prev = untracked(vis);
1660
+ effectRefs.push(effect(() => {
1661
+ const next = vis();
1662
+ const was = prev;
1663
+ prev = next;
1664
+ // only the hidden → visible TRANSITION refreshes — not the initial run
1665
+ if (was !== 'visible' && next === 'visible')
1666
+ untracked(tick);
1667
+ }, { injector: triggers.injector }));
1668
+ }
1669
+ if (triggers && onReconnect) {
1670
+ const online = triggers.online;
1671
+ let prev = untracked(online);
1672
+ effectRefs.push(effect(() => {
1673
+ const next = online();
1674
+ const was = prev;
1675
+ prev = next;
1676
+ if (!was && next)
1677
+ untracked(tick);
1678
+ }, { injector: triggers.injector }));
1679
+ }
1680
+ if (!hasInterval) {
1681
+ return {
1682
+ ...resource,
1683
+ destroy: () => {
1684
+ effectRefs.forEach((ref) => ref.destroy());
1685
+ resource.destroy();
1686
+ },
1687
+ };
1688
+ }
1373
1689
  // 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.
1374
- let sub = interval(refresh)
1690
+ let sub = interval(ms)
1375
1691
  .pipe(takeUntilDestroyed(destroyRef))
1376
1692
  .subscribe(tick);
1377
1693
  const reload = () => {
1378
1694
  sub.unsubscribe(); // do not conflict with manual reload
1379
1695
  const hasReloaded = resource.reload();
1380
1696
  // resubscribe after manual reload
1381
- sub = interval(refresh)
1697
+ sub = interval(ms)
1382
1698
  .pipe(takeUntilDestroyed(destroyRef))
1383
1699
  .subscribe(tick);
1384
1700
  return hasReloaded;
@@ -1388,6 +1704,7 @@ function refresh(resource, destroyRef, refresh, inactive) {
1388
1704
  reload,
1389
1705
  destroy: () => {
1390
1706
  sub.unsubscribe();
1707
+ effectRefs.forEach((ref) => ref.destroy());
1391
1708
  resource.destroy();
1392
1709
  },
1393
1710
  };
@@ -1434,6 +1751,7 @@ function retryOnError(res, opt, onError) {
1434
1751
 
1435
1752
  class ResourceSensors {
1436
1753
  networkStatus = sensor('networkStatus');
1754
+ pageVisibility = sensor('pageVisibility');
1437
1755
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ResourceSensors, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1438
1756
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ResourceSensors, providedIn: 'root' });
1439
1757
  }
@@ -1446,6 +1764,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1446
1764
  function injectNetworkStatus() {
1447
1765
  return inject(ResourceSensors).networkStatus;
1448
1766
  }
1767
+ function injectPageVisibility() {
1768
+ return inject(ResourceSensors).pageVisibility;
1769
+ }
1449
1770
 
1450
1771
  function toResourceObject(res) {
1451
1772
  return {
@@ -1491,7 +1812,7 @@ function provideTypedResourceOptions(token, valueOrFn) {
1491
1812
  function applyResourceRegistration(ref, register, injector) {
1492
1813
  if (!register)
1493
1814
  return;
1494
- const opt = register === true ? { suspends: false } : register;
1815
+ const opt = { suspends: register === 'suspend' };
1495
1816
  const run = injector
1496
1817
  ? (fn) => runInInjectionContext(injector, fn)
1497
1818
  : (fn) => fn();
@@ -1543,9 +1864,19 @@ function queryResource(request, options0) {
1543
1864
  const eq = options?.triggerOnSameRequest
1544
1865
  ? undefined
1545
1866
  : (options?.equalRequest ?? createEqualRequest());
1867
+ // Opt-in auto-pausing: `true` reads the ambient Activity boundary (no-op outside
1868
+ // one), a predicate is used directly. Composes with the manual `ctx.paused` path.
1869
+ const pauseOpt = options?.pause ?? false;
1870
+ const externallyPaused = pauseOpt === false
1871
+ ? () => false
1872
+ : typeof pauseOpt === 'function'
1873
+ ? pauseOpt
1874
+ : options?.injector
1875
+ ? runInInjectionContext(options.injector, injectPaused)
1876
+ : injectPaused();
1546
1877
  const requestCtx = { paused: PAUSED };
1547
1878
  const rawResult = computed(() => request(requestCtx), ...(ngDevMode ? [{ debugName: "rawResult" }] : /* istanbul ignore next */ []));
1548
- const paused = computed(() => rawResult() === PAUSED, ...(ngDevMode ? [{ debugName: "paused" }] : /* istanbul ignore next */ []));
1879
+ const paused = computed(() => rawResult() === PAUSED || externallyPaused(), ...(ngDevMode ? [{ debugName: "paused" }] : /* istanbul ignore next */ []));
1549
1880
  const rawRequest = computed(() => {
1550
1881
  const r = rawResult();
1551
1882
  return r === PAUSED ? undefined : (r ?? undefined);
@@ -1555,9 +1886,12 @@ function queryResource(request, options0) {
1555
1886
  return 'offline';
1556
1887
  if (cb.isOpen())
1557
1888
  return 'circuit-open';
1558
- // PAUSED makes rawRequest undefined, so it reports 'no-request' here (and skips polling),
1559
- // while stableRequest below HOLDS the last request so the value is kept (no refetch on resume).
1560
- if (!rawRequest())
1889
+ // Both pause sources report 'no-request' here ctx.paused makes rawRequest
1890
+ // undefined, while the external `pause` option still yields a real request, so it
1891
+ // must be checked explicitly. Either way this also stops polling/refresh triggers
1892
+ // (their inactive() guard reads disabledReason), while stableRequest below HOLDS
1893
+ // the last request so the value is kept (no refetch on resume).
1894
+ if (paused() || !rawRequest())
1561
1895
  return 'no-request';
1562
1896
  return null;
1563
1897
  }, ...(ngDevMode ? [{ debugName: "disabledReason" }] : /* istanbul ignore next */ []));
@@ -1587,8 +1921,10 @@ function queryResource(request, options0) {
1587
1921
  return eq(a, b);
1588
1922
  return a === b;
1589
1923
  } });
1924
+ const varyHeaders = typeof options?.cache === 'object' ? options.cache.varyHeaders : undefined;
1590
1925
  const hashFn = typeof options?.cache === 'object'
1591
- ? (options.cache.hash ?? hashRequest)
1926
+ ? (options.cache.hash ??
1927
+ ((r) => hashRequest(r, varyHeaders)))
1592
1928
  : hashRequest;
1593
1929
  const staleTime = typeof options?.cache === 'object' ? options.cache.staleTime : 0;
1594
1930
  const ttl = typeof options?.cache === 'object' ? options.cache.ttl : undefined;
@@ -1647,22 +1983,30 @@ function queryResource(request, options0) {
1647
1983
  key: entry.key,
1648
1984
  };
1649
1985
  } });
1650
- // A disabled (offline / circuit-open / no-request) or PAUSED resource must not poll.
1651
- resource = refresh(resource, destroyRef, options?.refresh, () => disabledReason() !== null);
1986
+ // A disabled (offline / circuit-open / no-request) or PAUSED resource must not poll
1987
+ // or react to focus/reconnect.
1988
+ resource = refresh(resource, destroyRef, options?.refresh, () => disabledReason() !== null, {
1989
+ injector: options?.injector ?? inject(Injector),
1990
+ visibility: injectPageVisibility(),
1991
+ online: networkAvailable,
1992
+ });
1652
1993
  resource = retryOnError(resource, options?.retry, options?.onError);
1653
1994
  resource = persistResourceValues(resource, options?.keepPrevious, options?.equal);
1654
1995
  const set = (value) => {
1655
1996
  resource.value.set(value);
1656
1997
  const k = untracked(cacheKey);
1657
1998
  if (options?.cache && k)
1658
- cache.store(k, new HttpResponse({
1999
+ cache.store(k,
2000
+ // statusText omitted — deprecated in Angular (HttpResponse defaults it)
2001
+ new HttpResponse({
1659
2002
  body: value,
1660
2003
  status: 200,
1661
- statusText: 'OK',
1662
2004
  }), staleTime, ttl, persist);
1663
2005
  };
1664
2006
  const update = (updater) => {
1665
- set(updater(untracked(resource.value)));
2007
+ // baseline on the COMPOSED value (cache-preferring): the cache entry can be newer
2008
+ // than resource.value (cross-tab sync, another instance's set)
2009
+ set(updater(untracked(value)));
1666
2010
  };
1667
2011
  const value = options?.cache
1668
2012
  ? toWritable(computed(() => cacheEntry()?.value ?? resource.value()), set, update)
@@ -1751,6 +2095,101 @@ function queryResource(request, options0) {
1751
2095
  return ref;
1752
2096
  }
1753
2097
 
2098
+ /**
2099
+ * Creates a paginated HTTP resource over {@link queryResource}: one page request at a
2100
+ * time, accumulated into a `pages` signal — cursor- and offset-based pagination both
2101
+ * fit through `getNextPageParam`. Each page request inherits the full queryResource
2102
+ * feature set (caching per page, retries, circuit breaker, refresh triggers).
2103
+ *
2104
+ * @example
2105
+ * ```ts
2106
+ * const posts = infiniteQueryResource<PostPage, PostPage, number>(
2107
+ * ({ pageParam }) => ({ url: '/api/posts', params: { page: pageParam } }),
2108
+ * {
2109
+ * initialPageParam: 0,
2110
+ * getNextPageParam: (last, all) => (last.items.length < 20 ? null : all.length),
2111
+ * cache: true,
2112
+ * },
2113
+ * );
2114
+ *
2115
+ * // template:
2116
+ * // @for (page of posts.pages(); track $index) { ... }
2117
+ * // <button (click)="posts.fetchNextPage()" [disabled]="!posts.hasNextPage()">More</button>
2118
+ * const flat = computed(() => posts.pages().flatMap((p) => p.items));
2119
+ * ```
2120
+ */
2121
+ function infiniteQueryResource(request, options) {
2122
+ const { initialPageParam, getNextPageParam, ...rest } = options;
2123
+ const injector = options.injector ?? inject(Injector);
2124
+ const pageParam = signal(initialPageParam, ...(ngDevMode ? [{ debugName: "pageParam" }] : /* istanbul ignore next */ []));
2125
+ // pages keyed by the param that produced them, so a reload of an already-loaded
2126
+ // page REPLACES its slot instead of appending a duplicate
2127
+ const loaded = signal([], ...(ngDevMode ? [{ debugName: "loaded" }] : /* istanbul ignore next */ []));
2128
+ const resource = queryResource(
2129
+ // forward queryResource's own context so the fn can return ctx.paused —
2130
+ // pausing holds the loaded pages and stops page fetches until unpaused
2131
+ (qctx) => request({ ...qctx, pageParam: pageParam() }), { ...rest, injector });
2132
+ const appendRef = effect(() => {
2133
+ if (resource.status() !== 'resolved')
2134
+ return;
2135
+ const page = resource.value();
2136
+ if (page === undefined)
2137
+ return;
2138
+ untracked(() => {
2139
+ const param = pageParam();
2140
+ loaded.update((list) => {
2141
+ const idx = list.findIndex((e) => Object.is(e.param, param));
2142
+ if (idx >= 0) {
2143
+ const copy = [...list];
2144
+ copy[idx] = { param, page };
2145
+ return copy;
2146
+ }
2147
+ return [...list, { param, page }];
2148
+ });
2149
+ });
2150
+ }, { ...(ngDevMode ? { debugName: "appendRef" } : /* istanbul ignore next */ {}), injector });
2151
+ const pages = computed(() => loaded().map((e) => e.page), ...(ngDevMode ? [{ debugName: "pages" }] : /* istanbul ignore next */ []));
2152
+ const nextPageParam = computed(() => {
2153
+ const all = pages();
2154
+ if (all.length === 0)
2155
+ return null;
2156
+ return getNextPageParam(all[all.length - 1], all) ?? null;
2157
+ }, ...(ngDevMode ? [{ debugName: "nextPageParam" }] : /* istanbul ignore next */ []));
2158
+ const hasNextPage = computed(() => nextPageParam() !== null, ...(ngDevMode ? [{ debugName: "hasNextPage" }] : /* istanbul ignore next */ []));
2159
+ const fetchNextPage = () => {
2160
+ if (untracked(resource.isLoading))
2161
+ return; // one page at a time
2162
+ const next = untracked(nextPageParam);
2163
+ if (next === null)
2164
+ return;
2165
+ pageParam.set(next);
2166
+ };
2167
+ const reset = () => {
2168
+ loaded.set([]);
2169
+ if (Object.is(untracked(pageParam), initialPageParam)) {
2170
+ resource.reload(); // param unchanged — force the refetch
2171
+ }
2172
+ else {
2173
+ pageParam.set(initialPageParam);
2174
+ }
2175
+ };
2176
+ return {
2177
+ pages,
2178
+ hasNextPage,
2179
+ isFetchingNextPage: computed(() => resource.isLoading() && loaded().length > 0),
2180
+ isLoading: resource.isLoading,
2181
+ status: resource.status,
2182
+ error: resource.error,
2183
+ fetchNextPage,
2184
+ reload: () => resource.reload(),
2185
+ reset,
2186
+ destroy: () => {
2187
+ appendRef.destroy();
2188
+ resource.destroy();
2189
+ },
2190
+ };
2191
+ }
2192
+
1754
2193
  function manualQueryResource(request, options) {
1755
2194
  const trigger = signal({ epoch: 0 }, { ...(ngDevMode ? { debugName: "trigger" } : /* istanbul ignore next */ {}), equal: (a, b) => a.epoch === b.epoch });
1756
2195
  const injector = options?.injector ?? inject(Injector);
@@ -1763,6 +2202,12 @@ function manualQueryResource(request, options) {
1763
2202
  return untracked(request);
1764
2203
  }, { ...(ngDevMode ? { debugName: "req" } : /* istanbul ignore next */ {}), equal: () => false });
1765
2204
  const resource = queryResource(req, options);
2205
+ // Shared across trigger() calls: a per-call watcher could observe the PREVIOUS
2206
+ // request's `resolved` status before this trigger's load flips the resource to
2207
+ // loading (effect ordering within a flush is unspecified) and resolve with stale
2208
+ // data; concurrent triggers would also cross-resolve each other's promises.
2209
+ let pending = [];
2210
+ let watcher = null;
1766
2211
  return {
1767
2212
  ...resource,
1768
2213
  trigger: (override, injectorOverride) => {
@@ -1771,15 +2216,41 @@ function manualQueryResource(request, options) {
1771
2216
  override,
1772
2217
  }));
1773
2218
  return new Promise((res, rej) => {
1774
- const watcher = nestedEffect(() => {
2219
+ if (untracked(req) === undefined) {
2220
+ // the request fn produced nothing — no load will ever start, so a watcher
2221
+ // would hang this promise forever
2222
+ rej(new Error('[@mmstack/resource]: trigger() produced no request (the request fn returned undefined)'));
2223
+ return;
2224
+ }
2225
+ pending.push({ res, rej });
2226
+ // an active watcher (concurrent trigger) settles ALL pending promises with
2227
+ // the final result of the latest request — TanStack-style latest-wins
2228
+ if (watcher)
2229
+ return;
2230
+ // only accept a settle AFTER the load for this trigger has been observed —
2231
+ // the pre-trigger status may still be a stale `resolved`/`error`
2232
+ let sawLoading = false;
2233
+ watcher = nestedEffect(() => {
1775
2234
  const status = resource.status();
1776
- if (status === 'resolved') {
1777
- watcher.destroy();
1778
- res(untracked(resource.value));
2235
+ if (status === 'loading' || status === 'reloading') {
2236
+ sawLoading = true;
2237
+ return;
1779
2238
  }
1780
- else if (status === 'error') {
1781
- watcher.destroy();
1782
- rej(untracked(resource.error));
2239
+ if (!sawLoading)
2240
+ return;
2241
+ if (status === 'resolved' || status === 'error') {
2242
+ const settled = pending;
2243
+ pending = [];
2244
+ watcher?.destroy();
2245
+ watcher = null;
2246
+ if (status === 'resolved') {
2247
+ const value = untracked(resource.value);
2248
+ settled.forEach((p) => p.res(value));
2249
+ }
2250
+ else {
2251
+ const err = untracked(resource.error);
2252
+ settled.forEach((p) => p.rej(err));
2253
+ }
1783
2254
  }
1784
2255
  }, { injector: injectorOverride ?? injector });
1785
2256
  });
@@ -1859,7 +2330,8 @@ function mutationResource(request, options0 = {}) {
1859
2330
  };
1860
2331
  // `register` is pulled out (and forced off on the inner query below) so the mutation ref is
1861
2332
  // the only thing registered into the transition scope, not its internal query resource.
1862
- const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, ...rest } = options;
2333
+ const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, invalidates, ...rest } = options;
2334
+ const cache = invalidates ? injectQueryCache(options.injector) : undefined;
1863
2335
  const requestEqual = equalRequest ?? createEqualRequest(equal);
1864
2336
  // A mutation is an imperative command, so `triggerOnSameRequest` means "fire on EVERY mutate(),
1865
2337
  // even with an identical body". By default we dedup an identical value/request while one is in
@@ -1962,8 +2434,19 @@ function mutationResource(request, options0 = {}) {
1962
2434
  .subscribe((result) => {
1963
2435
  if (result.status === 'error')
1964
2436
  onError?.(result.error, ctx);
1965
- else
2437
+ else {
1966
2438
  onSuccess?.(result.value, ctx);
2439
+ if (cache && invalidates) {
2440
+ const mutation = untracked(lastValue);
2441
+ const prefixes = typeof invalidates === 'function'
2442
+ ? invalidates(result.value, (mutation === NULL_VALUE ? undefined : mutation))
2443
+ : invalidates;
2444
+ // auto-keys are `${method}:${url}:...` — a `GET:`-prefixed url prefix hits
2445
+ // the url with any params/subpaths and every varyHeaders variant
2446
+ for (const prefix of prefixes)
2447
+ cache.invalidatePrefix(`GET:${prefix}`);
2448
+ }
2449
+ }
1967
2450
  onSettled?.(ctx);
1968
2451
  ctx = undefined;
1969
2452
  next.set(NULL_VALUE);
@@ -1972,15 +2455,31 @@ function mutationResource(request, options0 = {}) {
1972
2455
  const ref = {
1973
2456
  ...resource,
1974
2457
  destroy: () => {
2458
+ // queue first — a late queue flush must not poke an already-destroyed resource
2459
+ queueRef.destroy();
1975
2460
  statusSub.unsubscribe();
1976
2461
  resource.destroy();
1977
- queueRef.destroy();
1978
2462
  },
1979
2463
  mutate: (value, ictx) => {
1980
2464
  if (shouldQueue) {
1981
2465
  return queue.update((q) => [...q, [value, ictx]]);
1982
2466
  }
1983
2467
  else {
2468
+ // latest-wins: a mutation already in flight gets superseded (its request is
2469
+ // aborted by the request change), so its onSuccess/onError will never fire —
2470
+ // settle its context NOW so optimistic state can be rolled back/cleaned up
2471
+ if (untracked(next) !== NULL_VALUE) {
2472
+ if (isDevMode())
2473
+ 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.');
2474
+ try {
2475
+ onSettled?.(ctx);
2476
+ }
2477
+ catch (settleErr) {
2478
+ if (isDevMode())
2479
+ console.error('[@mmstack/resource]: error thrown in onSettled hook for a superseded mutation', settleErr);
2480
+ }
2481
+ ctx = undefined;
2482
+ }
1984
2483
  try {
1985
2484
  ctx = onMutate?.(value, ictx);
1986
2485
  next.set(value);
@@ -2008,5 +2507,5 @@ function mutationResource(request, options0 = {}) {
2008
2507
  * Generated bundle index. Do not edit.
2009
2508
  */
2010
2509
 
2011
- export { Cache, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
2510
+ export { Cache, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
2012
2511
  //# sourceMappingURL=mmstack-resource.mjs.map