@mmstack/resource 21.4.1 → 21.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -15
- package/fesm2022/mmstack-resource.mjs +793 -195
- package/fesm2022/mmstack-resource.mjs.map +1 -1
- package/package.json +1 -1
- package/types/mmstack-resource.d.ts +297 -56
|
@@ -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,
|
|
4
|
-
import { mutable, toWritable, keepPrevious, sensor, injectTransitionScope, nestedEffect } from '@mmstack/primitives';
|
|
5
|
-
import {
|
|
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
|
-
*
|
|
128
|
-
*
|
|
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
|
-
//
|
|
160
|
-
const cleanupInterval =
|
|
161
|
-
|
|
162
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
266
|
-
* for
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
366
|
+
this.setEntry({
|
|
317
367
|
value,
|
|
318
368
|
created: entry?.created ?? now,
|
|
319
369
|
updated: now,
|
|
320
|
-
useCount:
|
|
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
|
-
|
|
376
|
-
if (!
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
/**
|
|
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].
|
|
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:
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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,14 +786,121 @@ 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
|
+
const UNSAFE_HEADER_MESSAGES = new Map([
|
|
841
|
+
[
|
|
842
|
+
'cookie',
|
|
843
|
+
"[@mmstack/resource]: varyHeaders includes 'cookie'. Browser-attached cookies never appear on the request object (so this usually partitions nothing), and manually-set cookie values often rotate per-request (shredding the hit rate). The header IS still honored (digested) — but prefer varying on 'Authorization' or a tenant header.",
|
|
844
|
+
],
|
|
845
|
+
[
|
|
846
|
+
'set-cookie',
|
|
847
|
+
"[@mmstack/resource]: varyHeaders includes 'set-cookie'. Browser-attached cookies never appear on the request object (so this usually partitions nothing), and manually-set cookie values often rotate per-request (shredding the hit rate). The header IS still honored (digested) — but prefer varying on 'Authorization' or a tenant header.",
|
|
848
|
+
],
|
|
849
|
+
[
|
|
850
|
+
'authorization',
|
|
851
|
+
"[@mmstack/resource]: varyHeaders includes 'Authorization'. If your token rotates frequently (e.g., short-lived JWTs), this will cause 100% cache churn on refresh. Consider adding a namespace prefix with the users sub, not using it as a cache-key or using a custom 'cache.hash' function with a stable session/user ID instead.",
|
|
852
|
+
],
|
|
853
|
+
[
|
|
854
|
+
'x-request-id',
|
|
855
|
+
"[@mmstack/resource]: varyHeaders includes 'X-Request-ID'. This header is often set to a unique value per-request, which will cause 100% cache churn. Consider removing it from varyHeaders or using a custom 'cache.hash' function that ignores it.",
|
|
856
|
+
],
|
|
857
|
+
[
|
|
858
|
+
'x-correlation-id',
|
|
859
|
+
"[@mmstack/resource]: varyHeaders includes 'X-Correlation-ID'. This header is often set to a unique value per-request, which will cause 100% cache churn. Consider removing it from varyHeaders or using a custom 'cache.hash' function that ignores it.",
|
|
860
|
+
],
|
|
861
|
+
[
|
|
862
|
+
'if-none-match',
|
|
863
|
+
"[@mmstack/resource]: varyHeaders includes 'If-None-Match'. This header contains ETags that change whenever the server's resource version changes, which will cause cache misses on every update. Consider removing it from varyHeaders or using a custom 'cache.hash' function that ignores it.",
|
|
864
|
+
],
|
|
865
|
+
[
|
|
866
|
+
'if-modified-since',
|
|
867
|
+
"[@mmstack/resource]: varyHeaders includes 'If-Modified-Since'. This header contains timestamps that change whenever the server's resource version changes, which will cause cache misses on every update. Consider removing it from varyHeaders or using a custom 'cache.hash' function that ignores it.",
|
|
868
|
+
],
|
|
869
|
+
]);
|
|
870
|
+
function normalizeVaryHeaders(headers, names) {
|
|
871
|
+
const isDev = isDevMode();
|
|
872
|
+
return names
|
|
873
|
+
.map((n) => n.toLowerCase())
|
|
874
|
+
.toSorted()
|
|
875
|
+
.map((name) => {
|
|
876
|
+
if (isDev) {
|
|
877
|
+
const warning = UNSAFE_HEADER_MESSAGES.get(name);
|
|
878
|
+
if (warning)
|
|
879
|
+
console.warn(warning);
|
|
880
|
+
}
|
|
881
|
+
const value = readHeader(headers, name);
|
|
882
|
+
if (value === null)
|
|
883
|
+
return `${name}=`;
|
|
884
|
+
// known-safe values raw (readable, cheap); everything else digested, NEVER raw —
|
|
885
|
+
// keys are persisted to IndexedDB and broadcast across tabs
|
|
886
|
+
return SAFE_RAW_HEADERS.has(name)
|
|
887
|
+
? `${name}=${encodeURIComponent(value)}`
|
|
888
|
+
: `${name}=${digestHeaderValue(value)}`;
|
|
889
|
+
})
|
|
890
|
+
.join('&');
|
|
891
|
+
}
|
|
656
892
|
function normalizeParams(params) {
|
|
657
|
-
const p = params instanceof HttpParams
|
|
893
|
+
const p = params instanceof HttpParams
|
|
894
|
+
? params
|
|
895
|
+
: new HttpParams({ fromObject: params });
|
|
658
896
|
return p
|
|
659
897
|
.keys()
|
|
660
898
|
.toSorted()
|
|
661
899
|
.map((key) => {
|
|
662
900
|
const encodedKey = encodeURIComponent(key);
|
|
663
|
-
return (p.getAll(key) ?? [])
|
|
901
|
+
return (p.getAll(key) ?? [])
|
|
902
|
+
.map((v) => `${encodedKey}=${encodeURIComponent(v)}`)
|
|
903
|
+
.join('&');
|
|
664
904
|
})
|
|
665
905
|
.join('&');
|
|
666
906
|
}
|
|
@@ -680,7 +920,8 @@ function hashBody(body) {
|
|
|
680
920
|
entries.sort(([ak, av], [bk, bv]) => ak.localeCompare(bk) || av.localeCompare(bv));
|
|
681
921
|
return `FormData:${entries.map(([k, v]) => `${k}=${v}`).join('&')}`;
|
|
682
922
|
}
|
|
683
|
-
if (typeof URLSearchParams !== 'undefined' &&
|
|
923
|
+
if (typeof URLSearchParams !== 'undefined' &&
|
|
924
|
+
body instanceof URLSearchParams) {
|
|
684
925
|
const sp = new URLSearchParams(body);
|
|
685
926
|
sp.sort();
|
|
686
927
|
return `URLSearchParams:${sp.toString()}`;
|
|
@@ -697,20 +938,48 @@ function hashBody(body) {
|
|
|
697
938
|
* Builds a stable cache/dedupe key from an HTTP request shape (accepts both
|
|
698
939
|
* `HttpRequest` and `HttpResourceRequest`).
|
|
699
940
|
*
|
|
700
|
-
* Key composition: `${method}:${url}:${responseType}[:${params}][:${body}]`
|
|
941
|
+
* Key composition: `${method}:${url}:${responseType}[:${params}][:${body}][:${vary}]`
|
|
701
942
|
* - `method` defaults to `'GET'`, `responseType` to `'json'` (Angular defaults).
|
|
702
943
|
* - Query params are sorted alphabetically and URL-encoded for stability.
|
|
703
944
|
* - Body hashing handles `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`
|
|
704
945
|
* and typed arrays explicitly; everything else flows through key-sorted
|
|
705
946
|
* `JSON.stringify` via `hash()`.
|
|
947
|
+
* - `varyHeaders` (opt-in) mixes the named request headers into the key so responses
|
|
948
|
+
* that differ per header (e.g. `Authorization` → per-user, `Accept-Language`) get
|
|
949
|
+
* separate entries. Known-safe content-negotiation headers (`Accept`,
|
|
950
|
+
* `Accept-Language`, `Content-Language`, `Content-Type`) embed their value raw for
|
|
951
|
+
* readable keys; all other header VALUES are one-way digested, never embedded raw —
|
|
952
|
+
* keys are persisted to IndexedDB and broadcast across tabs.
|
|
706
953
|
*/
|
|
707
|
-
function hashRequest(req) {
|
|
954
|
+
function hashRequest(req, varyHeaders) {
|
|
708
955
|
const method = req.method ?? 'GET';
|
|
709
956
|
const responseType = req.responseType ?? 'json';
|
|
710
957
|
const base = `${method}:${req.url}:${responseType}`;
|
|
711
958
|
const params = req.params ? `:${normalizeParams(req.params)}` : '';
|
|
712
959
|
const body = req.body != null ? `:${hashBody(req.body)}` : '';
|
|
713
|
-
|
|
960
|
+
const vary = varyHeaders?.length
|
|
961
|
+
? `:vary(${normalizeVaryHeaders(req.headers, varyHeaders)})`
|
|
962
|
+
: '';
|
|
963
|
+
return base + params + body + vary;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* @internal
|
|
968
|
+
* Single-flight sharing: if a pending observable is already registered under `key`,
|
|
969
|
+
* return it; otherwise create one, share it (replaying the latest event to late
|
|
970
|
+
* subscribers), and deregister it on teardown/settle.
|
|
971
|
+
*
|
|
972
|
+
* Used by both the dedupe interceptor (keyed by full request hash, app-wide) and the
|
|
973
|
+
* cache interceptor (keyed by the CACHE key, guarding the miss/stale-revalidation path)
|
|
974
|
+
* — same mechanism, different keying/scope, so it lives here exactly once.
|
|
975
|
+
*/
|
|
976
|
+
function sharePending(pending, key, create) {
|
|
977
|
+
const existing = pending.get(key);
|
|
978
|
+
if (existing)
|
|
979
|
+
return existing;
|
|
980
|
+
const shared = create().pipe(finalize(() => pending.delete(key)), shareReplay({ bufferSize: 1, refCount: true }));
|
|
981
|
+
pending.set(key, shared);
|
|
982
|
+
return shared;
|
|
714
983
|
}
|
|
715
984
|
|
|
716
985
|
const CACHE_CONTEXT = new HttpContextToken(() => ({
|
|
@@ -730,6 +999,7 @@ function parseCacheControlHeader(req) {
|
|
|
730
999
|
noCache: false,
|
|
731
1000
|
mustRevalidate: false,
|
|
732
1001
|
immutable: false,
|
|
1002
|
+
isPrivate: false,
|
|
733
1003
|
maxAge: null,
|
|
734
1004
|
staleWhileRevalidate: null,
|
|
735
1005
|
};
|
|
@@ -753,6 +1023,9 @@ function parseCacheControlHeader(req) {
|
|
|
753
1023
|
case 'immutable':
|
|
754
1024
|
directives.immutable = true;
|
|
755
1025
|
break;
|
|
1026
|
+
case 'private':
|
|
1027
|
+
directives.isPrivate = true;
|
|
1028
|
+
break;
|
|
756
1029
|
case 'max-age': {
|
|
757
1030
|
if (!value)
|
|
758
1031
|
break;
|
|
@@ -761,7 +1034,7 @@ function parseCacheControlHeader(req) {
|
|
|
761
1034
|
directives.maxAge = parsedValue;
|
|
762
1035
|
break;
|
|
763
1036
|
}
|
|
764
|
-
case 's-
|
|
1037
|
+
case 's-maxage': {
|
|
765
1038
|
if (!value)
|
|
766
1039
|
break;
|
|
767
1040
|
const parsedValue = parseInt(value, 10);
|
|
@@ -779,7 +1052,7 @@ function parseCacheControlHeader(req) {
|
|
|
779
1052
|
}
|
|
780
1053
|
}
|
|
781
1054
|
}
|
|
782
|
-
// s-
|
|
1055
|
+
// s-maxage takes precedence over max-age
|
|
783
1056
|
if (sMaxAge !== null)
|
|
784
1057
|
directives.maxAge = sMaxAge;
|
|
785
1058
|
// if no store nothing else is relevant
|
|
@@ -789,6 +1062,7 @@ function parseCacheControlHeader(req) {
|
|
|
789
1062
|
noCache: false,
|
|
790
1063
|
mustRevalidate: false,
|
|
791
1064
|
immutable: false,
|
|
1065
|
+
isPrivate: directives.isPrivate,
|
|
792
1066
|
maxAge: null,
|
|
793
1067
|
staleWhileRevalidate: null,
|
|
794
1068
|
};
|
|
@@ -808,14 +1082,32 @@ function resolveTimings(cacheControl, optStaleTime, optTTL) {
|
|
|
808
1082
|
staleTime: Infinity,
|
|
809
1083
|
ttl: Infinity,
|
|
810
1084
|
};
|
|
811
|
-
if (cacheControl.maxAge !== null)
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
1085
|
+
if (cacheControl.maxAge !== null) {
|
|
1086
|
+
staleTime = cacheControl.maxAge * 1000;
|
|
1087
|
+
if (cacheControl.staleWhileRevalidate !== null) {
|
|
1088
|
+
ttl = staleTime + cacheControl.staleWhileRevalidate * 1000;
|
|
1089
|
+
}
|
|
1090
|
+
else if (ttl !== undefined) {
|
|
1091
|
+
// a configured total lifetime must never undercut the server's fresh window
|
|
1092
|
+
ttl = Math.max(ttl, staleTime);
|
|
1093
|
+
}
|
|
1094
|
+
// no swr + no configured ttl → leave undefined so the cache's default ttl applies
|
|
1095
|
+
// (the entry stays resident past max-age for ETag revalidation)
|
|
1096
|
+
}
|
|
1097
|
+
else if (cacheControl.staleWhileRevalidate !== null) {
|
|
1098
|
+
// swr without max-age: stale immediately, revalidatable for the window
|
|
1099
|
+
staleTime = 0;
|
|
1100
|
+
ttl = cacheControl.staleWhileRevalidate * 1000;
|
|
1101
|
+
}
|
|
1102
|
+
// if no-cache is set, we must always revalidate (the entry stays usable for conditional requests until ttl)
|
|
816
1103
|
if (cacheControl.noCache || cacheControl.mustRevalidate)
|
|
817
1104
|
staleTime = 0;
|
|
818
|
-
|
|
1105
|
+
// option-only path (no server freshness): a misconfigured ttl < staleTime clamps the
|
|
1106
|
+
// fresh window down, mirroring the cache's own internal clamp
|
|
1107
|
+
if (cacheControl.maxAge === null &&
|
|
1108
|
+
ttl !== undefined &&
|
|
1109
|
+
staleTime !== undefined &&
|
|
1110
|
+
ttl < staleTime) {
|
|
819
1111
|
staleTime = ttl;
|
|
820
1112
|
}
|
|
821
1113
|
return { staleTime, ttl };
|
|
@@ -829,6 +1121,10 @@ function resolveTimings(cacheControl, optStaleTime, optTTL) {
|
|
|
829
1121
|
* is made to the server, and the response is cached according to the configured TTL and staleness.
|
|
830
1122
|
* The interceptor also respects `Cache-Control` headers from the server.
|
|
831
1123
|
*
|
|
1124
|
+
* Cache-enabled requests are single-flighted per cache key: N concurrent consumers of
|
|
1125
|
+
* the same missing/stale entry share ONE network request. Non-cached requests are not
|
|
1126
|
+
* touched — pair with `createDedupeRequestsInterceptor` to coalesce those as well.
|
|
1127
|
+
*
|
|
832
1128
|
* @param allowedMethods - An array of HTTP methods for which caching should be enabled.
|
|
833
1129
|
* Defaults to `['GET', 'HEAD', 'OPTIONS']`.
|
|
834
1130
|
*
|
|
@@ -849,7 +1145,10 @@ function resolveTimings(cacheControl, optStaleTime, optTTL) {
|
|
|
849
1145
|
*/
|
|
850
1146
|
function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
|
|
851
1147
|
const CACHE_METHODS = new Set(allowedMethods);
|
|
1148
|
+
const inFlight = new Map();
|
|
852
1149
|
return (req, next) => {
|
|
1150
|
+
if (inject(PLATFORM_ID) === 'server')
|
|
1151
|
+
return next(req);
|
|
853
1152
|
const cache = injectQueryCache();
|
|
854
1153
|
if (!CACHE_METHODS.has(req.method))
|
|
855
1154
|
return next(req);
|
|
@@ -862,60 +1161,78 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
|
|
|
862
1161
|
if (entry && !entry.isStale)
|
|
863
1162
|
return of(entry.value);
|
|
864
1163
|
// resource itself handles case of showing stale data...the request must process as this will "refresh said data"
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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;
|
|
1164
|
+
return sharePending(inFlight, key, () => {
|
|
1165
|
+
const eTag = entry?.value.headers.get('ETag');
|
|
1166
|
+
const lastModified = entry?.value.headers.get('Last-Modified');
|
|
1167
|
+
if (eTag) {
|
|
1168
|
+
req = req.clone({ setHeaders: { 'If-None-Match': eTag } });
|
|
1169
|
+
}
|
|
1170
|
+
if (lastModified) {
|
|
1171
|
+
req = req.clone({ setHeaders: { 'If-Modified-Since': lastModified } });
|
|
901
1172
|
}
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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);
|
|
1173
|
+
if (opt.bustBrowserCache) {
|
|
1174
|
+
req = req.clone({
|
|
1175
|
+
setParams: { _cb: Date.now().toString() },
|
|
1176
|
+
});
|
|
911
1177
|
}
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
if (
|
|
915
|
-
|
|
1178
|
+
// non-JSON bodies (blob/arraybuffer) cannot survive the JSON persistence layer
|
|
1179
|
+
const persistable = req.responseType === 'json';
|
|
1180
|
+
if (opt.persist && !persistable && isDevMode()) {
|
|
1181
|
+
console.warn(`[@mmstack/resource]: persist was requested for a '${req.responseType}' response — such bodies don't survive JSON serialization, persisting skipped.`);
|
|
916
1182
|
}
|
|
917
|
-
return event
|
|
918
|
-
|
|
1183
|
+
return next(req).pipe(tap((event) => {
|
|
1184
|
+
if (!(event instanceof HttpResponse))
|
|
1185
|
+
return;
|
|
1186
|
+
if (event.ok) {
|
|
1187
|
+
const cacheControl = parseCacheControlHeader(event);
|
|
1188
|
+
if (cacheControl.noStore && !opt.ignoreCacheControl)
|
|
1189
|
+
return;
|
|
1190
|
+
const { staleTime, ttl } = opt.ignoreCacheControl
|
|
1191
|
+
? opt
|
|
1192
|
+
: resolveTimings(cacheControl, opt.staleTime, opt.ttl);
|
|
1193
|
+
if (ttl === 0)
|
|
1194
|
+
return; // no point
|
|
1195
|
+
// `Cache-Control: private` → fine to keep in memory, never on disk
|
|
1196
|
+
const persist = (opt.persist ?? false) &&
|
|
1197
|
+
persistable &&
|
|
1198
|
+
(opt.ignoreCacheControl || !cacheControl.isPrivate);
|
|
1199
|
+
const parsedResponse = opt.parse
|
|
1200
|
+
? // statusText omitted — deprecated in Angular (HttpResponse defaults it)
|
|
1201
|
+
new HttpResponse({
|
|
1202
|
+
body: opt.parse(event.body),
|
|
1203
|
+
headers: event.headers,
|
|
1204
|
+
status: event.status,
|
|
1205
|
+
url: event.url ?? undefined,
|
|
1206
|
+
})
|
|
1207
|
+
: event;
|
|
1208
|
+
cache.store(key, parsedResponse, staleTime, ttl, persist);
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
// 304 → server confirmed our cached entry is still valid. Re-stamp the
|
|
1212
|
+
// existing entry so subsequent reads within the new freshness window
|
|
1213
|
+
// don't trigger another revalidation round-trip.
|
|
1214
|
+
if (event.status === 304 && entry) {
|
|
1215
|
+
// ...unless the key was invalidated while this conditional request was in
|
|
1216
|
+
// flight (e.g. by a mutation) — re-storing would resurrect deleted data
|
|
1217
|
+
if (!cache.getUntracked(key))
|
|
1218
|
+
return;
|
|
1219
|
+
const cacheControl = parseCacheControlHeader(event);
|
|
1220
|
+
const { staleTime, ttl } = opt.ignoreCacheControl
|
|
1221
|
+
? opt
|
|
1222
|
+
: resolveTimings(cacheControl, opt.staleTime, opt.ttl);
|
|
1223
|
+
const persist = (opt.persist ?? false) &&
|
|
1224
|
+
persistable &&
|
|
1225
|
+
(opt.ignoreCacheControl || !cacheControl.isPrivate);
|
|
1226
|
+
cache.store(key, entry.value, staleTime, ttl, persist);
|
|
1227
|
+
}
|
|
1228
|
+
}), map((event) => {
|
|
1229
|
+
// handle 304 responses due to eTag/last-modified
|
|
1230
|
+
if (event instanceof HttpResponse && event.status === 304 && entry) {
|
|
1231
|
+
return entry.value;
|
|
1232
|
+
}
|
|
1233
|
+
return event;
|
|
1234
|
+
}));
|
|
1235
|
+
});
|
|
919
1236
|
};
|
|
920
1237
|
}
|
|
921
1238
|
|
|
@@ -1137,6 +1454,12 @@ function noDedupe(ctx = new HttpContext()) {
|
|
|
1137
1454
|
* only the first request will be sent to the server. Subsequent requests will
|
|
1138
1455
|
* receive the response from the first request.
|
|
1139
1456
|
*
|
|
1457
|
+
* Relationship to `createCacheInterceptor`: the cache interceptor has built-in
|
|
1458
|
+
* single-flight for CACHE-ENABLED requests (keyed by the cache key). This interceptor
|
|
1459
|
+
* covers everything the cache doesn't see — non-cached resources, plain HttpClient
|
|
1460
|
+
* calls, DELETEs — keyed by the request hash. Installing both is the recommended
|
|
1461
|
+
* setup; where they overlap, this one degrades to a no-op passthrough.
|
|
1462
|
+
*
|
|
1140
1463
|
* @param allowed - An array of HTTP methods for which deduplication should be enabled.
|
|
1141
1464
|
* Defaults to `['GET', 'DELETE', 'HEAD', 'OPTIONS']`.
|
|
1142
1465
|
* @param keyFn - Optional function to compute the dedupe key from a request.
|
|
@@ -1171,13 +1494,7 @@ function createDedupeRequestsInterceptor(allowed = ['GET', 'DELETE', 'HEAD', 'OP
|
|
|
1171
1494
|
return (req, next) => {
|
|
1172
1495
|
if (!DEDUPE_METHODS.has(req.method) || req.context.get(NO_DEDUPE))
|
|
1173
1496
|
return next(req);
|
|
1174
|
-
|
|
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;
|
|
1497
|
+
return sharePending(inFlight, keyFn(req), () => next(req));
|
|
1181
1498
|
};
|
|
1182
1499
|
}
|
|
1183
1500
|
|
|
@@ -1350,6 +1667,57 @@ function hasSlowConnection() {
|
|
|
1350
1667
|
return false;
|
|
1351
1668
|
}
|
|
1352
1669
|
|
|
1670
|
+
/**
|
|
1671
|
+
* Deep merges multiple circuit breaker options.
|
|
1672
|
+
* The latter options override the former.
|
|
1673
|
+
*/
|
|
1674
|
+
function mergeCircuitBreakerOptions(global, query, local) {
|
|
1675
|
+
if (!global && !query && !local)
|
|
1676
|
+
return undefined;
|
|
1677
|
+
return {
|
|
1678
|
+
...(global === true ? {} : global),
|
|
1679
|
+
...(query === true ? {} : query),
|
|
1680
|
+
...(local === true ? {} : local),
|
|
1681
|
+
};
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* Deep merges multiple retry options.
|
|
1685
|
+
* The latter options override the former.
|
|
1686
|
+
*/
|
|
1687
|
+
function mergeRetryOptions(global, query, local) {
|
|
1688
|
+
if (global === undefined && query === undefined && local === undefined)
|
|
1689
|
+
return undefined;
|
|
1690
|
+
return {
|
|
1691
|
+
...(typeof global === 'number' ? { max: global } : global),
|
|
1692
|
+
...(typeof query === 'number' ? { max: query } : query),
|
|
1693
|
+
...(typeof local === 'number' ? { max: local } : local),
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
/**
|
|
1697
|
+
* Deep merges multiple cache options.
|
|
1698
|
+
* The latter options override the former.
|
|
1699
|
+
*/
|
|
1700
|
+
function mergeCacheOptions(query, local) {
|
|
1701
|
+
if (query === undefined && local === undefined)
|
|
1702
|
+
return undefined;
|
|
1703
|
+
return {
|
|
1704
|
+
...(query === true ? {} : query),
|
|
1705
|
+
...(local === true ? {} : local),
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
/**
|
|
1709
|
+
* Deep merges multiple refresh options.
|
|
1710
|
+
* The latter options override the former.
|
|
1711
|
+
*/
|
|
1712
|
+
function mergeRefreshOptions(query, local) {
|
|
1713
|
+
if (query === undefined && local === undefined)
|
|
1714
|
+
return undefined;
|
|
1715
|
+
return {
|
|
1716
|
+
...(typeof query === 'number' ? { interval: query } : query),
|
|
1717
|
+
...(typeof local === 'number' ? { interval: local } : local),
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1353
1721
|
function persistResourceValues(resource, shouldPersist = false, equal) {
|
|
1354
1722
|
if (!shouldPersist)
|
|
1355
1723
|
return resource;
|
|
@@ -1361,24 +1729,61 @@ function persistResourceValues(resource, shouldPersist = false, equal) {
|
|
|
1361
1729
|
};
|
|
1362
1730
|
}
|
|
1363
1731
|
|
|
1364
|
-
// refresh resource every n
|
|
1365
|
-
function refresh(resource, destroyRef,
|
|
1366
|
-
|
|
1732
|
+
// refresh resource every n milliseconds and/or on visibility/reconnect transitions.
|
|
1733
|
+
function refresh(resource, destroyRef, opt, inactive, triggers) {
|
|
1734
|
+
const normalized = typeof opt === 'number' ? { interval: opt } : (opt ?? {});
|
|
1735
|
+
const { interval: ms, onFocus = false, onReconnect = false, } = normalized;
|
|
1736
|
+
const hasInterval = !!ms; // 0 excluded — not a valid polling cadence
|
|
1737
|
+
const hasTriggerEffects = !!triggers && (onFocus || onReconnect);
|
|
1738
|
+
if (!hasInterval && !hasTriggerEffects)
|
|
1367
1739
|
return resource; // no refresh requested
|
|
1368
1740
|
const tick = () => {
|
|
1369
1741
|
if (inactive?.())
|
|
1370
|
-
return; // disabled / paused → skip
|
|
1742
|
+
return; // disabled / paused → skip
|
|
1371
1743
|
resource.reload();
|
|
1372
1744
|
};
|
|
1745
|
+
const effectRefs = [];
|
|
1746
|
+
if (triggers && onFocus) {
|
|
1747
|
+
const vis = triggers.visibility;
|
|
1748
|
+
let prev = untracked(vis);
|
|
1749
|
+
effectRefs.push(effect(() => {
|
|
1750
|
+
const next = vis();
|
|
1751
|
+
const was = prev;
|
|
1752
|
+
prev = next;
|
|
1753
|
+
// only the hidden → visible TRANSITION refreshes — not the initial run
|
|
1754
|
+
if (was !== 'visible' && next === 'visible')
|
|
1755
|
+
untracked(tick);
|
|
1756
|
+
}, { injector: triggers.injector }));
|
|
1757
|
+
}
|
|
1758
|
+
if (triggers && onReconnect) {
|
|
1759
|
+
const online = triggers.online;
|
|
1760
|
+
let prev = untracked(online);
|
|
1761
|
+
effectRefs.push(effect(() => {
|
|
1762
|
+
const next = online();
|
|
1763
|
+
const was = prev;
|
|
1764
|
+
prev = next;
|
|
1765
|
+
if (!was && next)
|
|
1766
|
+
untracked(tick);
|
|
1767
|
+
}, { injector: triggers.injector }));
|
|
1768
|
+
}
|
|
1769
|
+
if (!hasInterval) {
|
|
1770
|
+
return {
|
|
1771
|
+
...resource,
|
|
1772
|
+
destroy: () => {
|
|
1773
|
+
effectRefs.forEach((ref) => ref.destroy());
|
|
1774
|
+
resource.destroy();
|
|
1775
|
+
},
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1373
1778
|
// 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(
|
|
1779
|
+
let sub = interval(ms)
|
|
1375
1780
|
.pipe(takeUntilDestroyed(destroyRef))
|
|
1376
1781
|
.subscribe(tick);
|
|
1377
1782
|
const reload = () => {
|
|
1378
1783
|
sub.unsubscribe(); // do not conflict with manual reload
|
|
1379
1784
|
const hasReloaded = resource.reload();
|
|
1380
1785
|
// resubscribe after manual reload
|
|
1381
|
-
sub = interval(
|
|
1786
|
+
sub = interval(ms)
|
|
1382
1787
|
.pipe(takeUntilDestroyed(destroyRef))
|
|
1383
1788
|
.subscribe(tick);
|
|
1384
1789
|
return hasReloaded;
|
|
@@ -1388,6 +1793,7 @@ function refresh(resource, destroyRef, refresh, inactive) {
|
|
|
1388
1793
|
reload,
|
|
1389
1794
|
destroy: () => {
|
|
1390
1795
|
sub.unsubscribe();
|
|
1796
|
+
effectRefs.forEach((ref) => ref.destroy());
|
|
1391
1797
|
resource.destroy();
|
|
1392
1798
|
},
|
|
1393
1799
|
};
|
|
@@ -1434,6 +1840,7 @@ function retryOnError(res, opt, onError) {
|
|
|
1434
1840
|
|
|
1435
1841
|
class ResourceSensors {
|
|
1436
1842
|
networkStatus = sensor('networkStatus');
|
|
1843
|
+
pageVisibility = sensor('pageVisibility');
|
|
1437
1844
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ResourceSensors, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1438
1845
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ResourceSensors, providedIn: 'root' });
|
|
1439
1846
|
}
|
|
@@ -1446,6 +1853,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
1446
1853
|
function injectNetworkStatus() {
|
|
1447
1854
|
return inject(ResourceSensors).networkStatus;
|
|
1448
1855
|
}
|
|
1856
|
+
function injectPageVisibility() {
|
|
1857
|
+
return inject(ResourceSensors).pageVisibility;
|
|
1858
|
+
}
|
|
1449
1859
|
|
|
1450
1860
|
function toResourceObject(res) {
|
|
1451
1861
|
return {
|
|
@@ -1527,10 +1937,16 @@ function injectQueryResourceOptions(injector) {
|
|
|
1527
1937
|
const PAUSED = Symbol('@mmstack/resource:paused');
|
|
1528
1938
|
function queryResource(request, options0) {
|
|
1529
1939
|
// Two-layer option injection: per-call > provideQueryResourceOptions > provideResourceOptions.
|
|
1940
|
+
const globalOpts = injectResourceOptions(options0?.injector);
|
|
1941
|
+
const queryOpts = injectQueryResourceOptions(options0?.injector);
|
|
1530
1942
|
const options = {
|
|
1531
|
-
...
|
|
1532
|
-
...
|
|
1943
|
+
...globalOpts,
|
|
1944
|
+
...queryOpts,
|
|
1533
1945
|
...options0,
|
|
1946
|
+
cache: mergeCacheOptions(queryOpts.cache, options0?.cache),
|
|
1947
|
+
circuitBreaker: mergeCircuitBreakerOptions(globalOpts.circuitBreaker, queryOpts.circuitBreaker, options0?.circuitBreaker),
|
|
1948
|
+
retry: mergeRetryOptions(globalOpts.retry, queryOpts.retry, options0?.retry),
|
|
1949
|
+
refresh: mergeRefreshOptions(queryOpts.refresh, options0?.refresh),
|
|
1534
1950
|
};
|
|
1535
1951
|
const cache = injectQueryCache(options?.injector);
|
|
1536
1952
|
const destroyRef = options?.injector
|
|
@@ -1543,9 +1959,19 @@ function queryResource(request, options0) {
|
|
|
1543
1959
|
const eq = options?.triggerOnSameRequest
|
|
1544
1960
|
? undefined
|
|
1545
1961
|
: (options?.equalRequest ?? createEqualRequest());
|
|
1962
|
+
// Opt-in auto-pausing: `true` reads the ambient Activity boundary (no-op outside
|
|
1963
|
+
// one), a predicate is used directly. Composes with the manual `ctx.paused` path.
|
|
1964
|
+
const pauseOpt = options?.pause ?? false;
|
|
1965
|
+
const externallyPaused = pauseOpt === false
|
|
1966
|
+
? () => false
|
|
1967
|
+
: typeof pauseOpt === 'function'
|
|
1968
|
+
? pauseOpt
|
|
1969
|
+
: options?.injector
|
|
1970
|
+
? runInInjectionContext(options.injector, injectPaused)
|
|
1971
|
+
: injectPaused();
|
|
1546
1972
|
const requestCtx = { paused: PAUSED };
|
|
1547
1973
|
const rawResult = computed(() => request(requestCtx), ...(ngDevMode ? [{ debugName: "rawResult" }] : /* istanbul ignore next */ []));
|
|
1548
|
-
const paused = computed(() => rawResult() === PAUSED, ...(ngDevMode ? [{ debugName: "paused" }] : /* istanbul ignore next */ []));
|
|
1974
|
+
const paused = computed(() => rawResult() === PAUSED || externallyPaused(), ...(ngDevMode ? [{ debugName: "paused" }] : /* istanbul ignore next */ []));
|
|
1549
1975
|
const rawRequest = computed(() => {
|
|
1550
1976
|
const r = rawResult();
|
|
1551
1977
|
return r === PAUSED ? undefined : (r ?? undefined);
|
|
@@ -1555,9 +1981,12 @@ function queryResource(request, options0) {
|
|
|
1555
1981
|
return 'offline';
|
|
1556
1982
|
if (cb.isOpen())
|
|
1557
1983
|
return 'circuit-open';
|
|
1558
|
-
//
|
|
1559
|
-
// while
|
|
1560
|
-
|
|
1984
|
+
// Both pause sources report 'no-request' here — ctx.paused makes rawRequest
|
|
1985
|
+
// undefined, while the external `pause` option still yields a real request, so it
|
|
1986
|
+
// must be checked explicitly. Either way this also stops polling/refresh triggers
|
|
1987
|
+
// (their inactive() guard reads disabledReason), while stableRequest below HOLDS
|
|
1988
|
+
// the last request so the value is kept (no refetch on resume).
|
|
1989
|
+
if (paused() || !rawRequest())
|
|
1561
1990
|
return 'no-request';
|
|
1562
1991
|
return null;
|
|
1563
1992
|
}, ...(ngDevMode ? [{ debugName: "disabledReason" }] : /* istanbul ignore next */ []));
|
|
@@ -1587,8 +2016,10 @@ function queryResource(request, options0) {
|
|
|
1587
2016
|
return eq(a, b);
|
|
1588
2017
|
return a === b;
|
|
1589
2018
|
} });
|
|
2019
|
+
const varyHeaders = typeof options?.cache === 'object' ? options.cache.varyHeaders : undefined;
|
|
1590
2020
|
const hashFn = typeof options?.cache === 'object'
|
|
1591
|
-
? (options.cache.hash ??
|
|
2021
|
+
? (options.cache.hash ??
|
|
2022
|
+
((r) => hashRequest(r, varyHeaders)))
|
|
1592
2023
|
: hashRequest;
|
|
1593
2024
|
const staleTime = typeof options?.cache === 'object' ? options.cache.staleTime : 0;
|
|
1594
2025
|
const ttl = typeof options?.cache === 'object' ? options.cache.ttl : undefined;
|
|
@@ -1647,22 +2078,30 @@ function queryResource(request, options0) {
|
|
|
1647
2078
|
key: entry.key,
|
|
1648
2079
|
};
|
|
1649
2080
|
} });
|
|
1650
|
-
// A disabled (offline / circuit-open / no-request) or PAUSED resource must not poll
|
|
1651
|
-
|
|
2081
|
+
// A disabled (offline / circuit-open / no-request) or PAUSED resource must not poll
|
|
2082
|
+
// or react to focus/reconnect.
|
|
2083
|
+
resource = refresh(resource, destroyRef, options?.refresh, () => disabledReason() !== null, {
|
|
2084
|
+
injector: options?.injector ?? inject(Injector),
|
|
2085
|
+
visibility: injectPageVisibility(),
|
|
2086
|
+
online: networkAvailable,
|
|
2087
|
+
});
|
|
1652
2088
|
resource = retryOnError(resource, options?.retry, options?.onError);
|
|
1653
2089
|
resource = persistResourceValues(resource, options?.keepPrevious, options?.equal);
|
|
1654
2090
|
const set = (value) => {
|
|
1655
2091
|
resource.value.set(value);
|
|
1656
2092
|
const k = untracked(cacheKey);
|
|
1657
2093
|
if (options?.cache && k)
|
|
1658
|
-
cache.store(k,
|
|
2094
|
+
cache.store(k,
|
|
2095
|
+
// statusText omitted — deprecated in Angular (HttpResponse defaults it)
|
|
2096
|
+
new HttpResponse({
|
|
1659
2097
|
body: value,
|
|
1660
2098
|
status: 200,
|
|
1661
|
-
statusText: 'OK',
|
|
1662
2099
|
}), staleTime, ttl, persist);
|
|
1663
2100
|
};
|
|
1664
2101
|
const update = (updater) => {
|
|
1665
|
-
|
|
2102
|
+
// baseline on the COMPOSED value (cache-preferring): the cache entry can be newer
|
|
2103
|
+
// than resource.value (cross-tab sync, another instance's set)
|
|
2104
|
+
set(updater(untracked(value)));
|
|
1666
2105
|
};
|
|
1667
2106
|
const value = options?.cache
|
|
1668
2107
|
? toWritable(computed(() => cacheEntry()?.value ?? resource.value()), set, update)
|
|
@@ -1751,6 +2190,101 @@ function queryResource(request, options0) {
|
|
|
1751
2190
|
return ref;
|
|
1752
2191
|
}
|
|
1753
2192
|
|
|
2193
|
+
/**
|
|
2194
|
+
* Creates a paginated HTTP resource over {@link queryResource}: one page request at a
|
|
2195
|
+
* time, accumulated into a `pages` signal — cursor- and offset-based pagination both
|
|
2196
|
+
* fit through `getNextPageParam`. Each page request inherits the full queryResource
|
|
2197
|
+
* feature set (caching per page, retries, circuit breaker, refresh triggers).
|
|
2198
|
+
*
|
|
2199
|
+
* @example
|
|
2200
|
+
* ```ts
|
|
2201
|
+
* const posts = infiniteQueryResource<PostPage, PostPage, number>(
|
|
2202
|
+
* ({ pageParam }) => ({ url: '/api/posts', params: { page: pageParam } }),
|
|
2203
|
+
* {
|
|
2204
|
+
* initialPageParam: 0,
|
|
2205
|
+
* getNextPageParam: (last, all) => (last.items.length < 20 ? null : all.length),
|
|
2206
|
+
* cache: true,
|
|
2207
|
+
* },
|
|
2208
|
+
* );
|
|
2209
|
+
*
|
|
2210
|
+
* // template:
|
|
2211
|
+
* // @for (page of posts.pages(); track $index) { ... }
|
|
2212
|
+
* // <button (click)="posts.fetchNextPage()" [disabled]="!posts.hasNextPage()">More</button>
|
|
2213
|
+
* const flat = computed(() => posts.pages().flatMap((p) => p.items));
|
|
2214
|
+
* ```
|
|
2215
|
+
*/
|
|
2216
|
+
function infiniteQueryResource(request, options) {
|
|
2217
|
+
const { initialPageParam, getNextPageParam, ...rest } = options;
|
|
2218
|
+
const injector = options.injector ?? inject(Injector);
|
|
2219
|
+
const pageParam = signal(initialPageParam, ...(ngDevMode ? [{ debugName: "pageParam" }] : /* istanbul ignore next */ []));
|
|
2220
|
+
// pages keyed by the param that produced them, so a reload of an already-loaded
|
|
2221
|
+
// page REPLACES its slot instead of appending a duplicate
|
|
2222
|
+
const loaded = signal([], ...(ngDevMode ? [{ debugName: "loaded" }] : /* istanbul ignore next */ []));
|
|
2223
|
+
const resource = queryResource(
|
|
2224
|
+
// forward queryResource's own context so the fn can return ctx.paused —
|
|
2225
|
+
// pausing holds the loaded pages and stops page fetches until unpaused
|
|
2226
|
+
(qctx) => request({ ...qctx, pageParam: pageParam() }), { ...rest, injector });
|
|
2227
|
+
const appendRef = effect(() => {
|
|
2228
|
+
if (resource.status() !== 'resolved')
|
|
2229
|
+
return;
|
|
2230
|
+
const page = resource.value();
|
|
2231
|
+
if (page === undefined)
|
|
2232
|
+
return;
|
|
2233
|
+
untracked(() => {
|
|
2234
|
+
const param = pageParam();
|
|
2235
|
+
loaded.update((list) => {
|
|
2236
|
+
const idx = list.findIndex((e) => Object.is(e.param, param));
|
|
2237
|
+
if (idx >= 0) {
|
|
2238
|
+
const copy = [...list];
|
|
2239
|
+
copy[idx] = { param, page };
|
|
2240
|
+
return copy;
|
|
2241
|
+
}
|
|
2242
|
+
return [...list, { param, page }];
|
|
2243
|
+
});
|
|
2244
|
+
});
|
|
2245
|
+
}, { ...(ngDevMode ? { debugName: "appendRef" } : /* istanbul ignore next */ {}), injector });
|
|
2246
|
+
const pages = computed(() => loaded().map((e) => e.page), ...(ngDevMode ? [{ debugName: "pages" }] : /* istanbul ignore next */ []));
|
|
2247
|
+
const nextPageParam = computed(() => {
|
|
2248
|
+
const all = pages();
|
|
2249
|
+
if (all.length === 0)
|
|
2250
|
+
return null;
|
|
2251
|
+
return getNextPageParam(all[all.length - 1], all) ?? null;
|
|
2252
|
+
}, ...(ngDevMode ? [{ debugName: "nextPageParam" }] : /* istanbul ignore next */ []));
|
|
2253
|
+
const hasNextPage = computed(() => nextPageParam() !== null, ...(ngDevMode ? [{ debugName: "hasNextPage" }] : /* istanbul ignore next */ []));
|
|
2254
|
+
const fetchNextPage = () => {
|
|
2255
|
+
if (untracked(resource.isLoading))
|
|
2256
|
+
return; // one page at a time
|
|
2257
|
+
const next = untracked(nextPageParam);
|
|
2258
|
+
if (next === null)
|
|
2259
|
+
return;
|
|
2260
|
+
pageParam.set(next);
|
|
2261
|
+
};
|
|
2262
|
+
const reset = () => {
|
|
2263
|
+
loaded.set([]);
|
|
2264
|
+
if (Object.is(untracked(pageParam), initialPageParam)) {
|
|
2265
|
+
resource.reload(); // param unchanged — force the refetch
|
|
2266
|
+
}
|
|
2267
|
+
else {
|
|
2268
|
+
pageParam.set(initialPageParam);
|
|
2269
|
+
}
|
|
2270
|
+
};
|
|
2271
|
+
return {
|
|
2272
|
+
pages,
|
|
2273
|
+
hasNextPage,
|
|
2274
|
+
isFetchingNextPage: computed(() => resource.isLoading() && loaded().length > 0),
|
|
2275
|
+
isLoading: resource.isLoading,
|
|
2276
|
+
status: resource.status,
|
|
2277
|
+
error: resource.error,
|
|
2278
|
+
fetchNextPage,
|
|
2279
|
+
reload: () => resource.reload(),
|
|
2280
|
+
reset,
|
|
2281
|
+
destroy: () => {
|
|
2282
|
+
appendRef.destroy();
|
|
2283
|
+
resource.destroy();
|
|
2284
|
+
},
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
|
|
1754
2288
|
function manualQueryResource(request, options) {
|
|
1755
2289
|
const trigger = signal({ epoch: 0 }, { ...(ngDevMode ? { debugName: "trigger" } : /* istanbul ignore next */ {}), equal: (a, b) => a.epoch === b.epoch });
|
|
1756
2290
|
const injector = options?.injector ?? inject(Injector);
|
|
@@ -1763,6 +2297,12 @@ function manualQueryResource(request, options) {
|
|
|
1763
2297
|
return untracked(request);
|
|
1764
2298
|
}, { ...(ngDevMode ? { debugName: "req" } : /* istanbul ignore next */ {}), equal: () => false });
|
|
1765
2299
|
const resource = queryResource(req, options);
|
|
2300
|
+
// Shared across trigger() calls: a per-call watcher could observe the PREVIOUS
|
|
2301
|
+
// request's `resolved` status before this trigger's load flips the resource to
|
|
2302
|
+
// loading (effect ordering within a flush is unspecified) and resolve with stale
|
|
2303
|
+
// data; concurrent triggers would also cross-resolve each other's promises.
|
|
2304
|
+
let pending = [];
|
|
2305
|
+
let watcher = null;
|
|
1766
2306
|
return {
|
|
1767
2307
|
...resource,
|
|
1768
2308
|
trigger: (override, injectorOverride) => {
|
|
@@ -1771,15 +2311,41 @@ function manualQueryResource(request, options) {
|
|
|
1771
2311
|
override,
|
|
1772
2312
|
}));
|
|
1773
2313
|
return new Promise((res, rej) => {
|
|
1774
|
-
|
|
2314
|
+
if (untracked(req) === undefined) {
|
|
2315
|
+
// the request fn produced nothing — no load will ever start, so a watcher
|
|
2316
|
+
// would hang this promise forever
|
|
2317
|
+
rej(new Error('[@mmstack/resource]: trigger() produced no request (the request fn returned undefined)'));
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
pending.push({ res, rej });
|
|
2321
|
+
// an active watcher (concurrent trigger) settles ALL pending promises with
|
|
2322
|
+
// the final result of the latest request — TanStack-style latest-wins
|
|
2323
|
+
if (watcher)
|
|
2324
|
+
return;
|
|
2325
|
+
// only accept a settle AFTER the load for this trigger has been observed —
|
|
2326
|
+
// the pre-trigger status may still be a stale `resolved`/`error`
|
|
2327
|
+
let sawLoading = false;
|
|
2328
|
+
watcher = nestedEffect(() => {
|
|
1775
2329
|
const status = resource.status();
|
|
1776
|
-
if (status === '
|
|
1777
|
-
|
|
1778
|
-
|
|
2330
|
+
if (status === 'loading' || status === 'reloading') {
|
|
2331
|
+
sawLoading = true;
|
|
2332
|
+
return;
|
|
1779
2333
|
}
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
2334
|
+
if (!sawLoading)
|
|
2335
|
+
return;
|
|
2336
|
+
if (status === 'resolved' || status === 'error') {
|
|
2337
|
+
const settled = pending;
|
|
2338
|
+
pending = [];
|
|
2339
|
+
watcher?.destroy();
|
|
2340
|
+
watcher = null;
|
|
2341
|
+
if (status === 'resolved') {
|
|
2342
|
+
const value = untracked(resource.value);
|
|
2343
|
+
settled.forEach((p) => p.res(value));
|
|
2344
|
+
}
|
|
2345
|
+
else {
|
|
2346
|
+
const err = untracked(resource.error);
|
|
2347
|
+
settled.forEach((p) => p.rej(err));
|
|
2348
|
+
}
|
|
1783
2349
|
}
|
|
1784
2350
|
}, { injector: injectorOverride ?? injector });
|
|
1785
2351
|
});
|
|
@@ -1852,14 +2418,19 @@ function injectMutationResourceOptions(injector) {
|
|
|
1852
2418
|
*/
|
|
1853
2419
|
function mutationResource(request, options0 = {}) {
|
|
1854
2420
|
// Two-layer option injection: per-call > provideMutationResourceOptions > provideResourceOptions.
|
|
2421
|
+
const globalOpts = injectResourceOptions(options0.injector);
|
|
2422
|
+
const mutOpts = injectMutationResourceOptions(options0.injector);
|
|
1855
2423
|
const options = {
|
|
1856
|
-
...
|
|
1857
|
-
...
|
|
2424
|
+
...globalOpts,
|
|
2425
|
+
...mutOpts,
|
|
1858
2426
|
...options0,
|
|
2427
|
+
circuitBreaker: mergeCircuitBreakerOptions(globalOpts.circuitBreaker, mutOpts.circuitBreaker, options0?.circuitBreaker),
|
|
2428
|
+
retry: mergeRetryOptions(globalOpts.retry, mutOpts.retry, options0?.retry),
|
|
1859
2429
|
};
|
|
1860
2430
|
// `register` is pulled out (and forced off on the inner query below) so the mutation ref is
|
|
1861
2431
|
// the only thing registered into the transition scope, not its internal query resource.
|
|
1862
|
-
const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, ...rest } = options;
|
|
2432
|
+
const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, invalidates, ...rest } = options;
|
|
2433
|
+
const cache = invalidates ? injectQueryCache(options.injector) : undefined;
|
|
1863
2434
|
const requestEqual = equalRequest ?? createEqualRequest(equal);
|
|
1864
2435
|
// A mutation is an imperative command, so `triggerOnSameRequest` means "fire on EVERY mutate(),
|
|
1865
2436
|
// even with an identical body". By default we dedup an identical value/request while one is in
|
|
@@ -1962,8 +2533,19 @@ function mutationResource(request, options0 = {}) {
|
|
|
1962
2533
|
.subscribe((result) => {
|
|
1963
2534
|
if (result.status === 'error')
|
|
1964
2535
|
onError?.(result.error, ctx);
|
|
1965
|
-
else
|
|
2536
|
+
else {
|
|
1966
2537
|
onSuccess?.(result.value, ctx);
|
|
2538
|
+
if (cache && invalidates) {
|
|
2539
|
+
const mutation = untracked(lastValue);
|
|
2540
|
+
const prefixes = typeof invalidates === 'function'
|
|
2541
|
+
? invalidates(result.value, (mutation === NULL_VALUE ? undefined : mutation))
|
|
2542
|
+
: invalidates;
|
|
2543
|
+
// auto-keys are `${method}:${url}:...` — a `GET:`-prefixed url prefix hits
|
|
2544
|
+
// the url with any params/subpaths and every varyHeaders variant
|
|
2545
|
+
for (const prefix of prefixes)
|
|
2546
|
+
cache.invalidatePrefix(`GET:${prefix}`);
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
1967
2549
|
onSettled?.(ctx);
|
|
1968
2550
|
ctx = undefined;
|
|
1969
2551
|
next.set(NULL_VALUE);
|
|
@@ -1972,15 +2554,31 @@ function mutationResource(request, options0 = {}) {
|
|
|
1972
2554
|
const ref = {
|
|
1973
2555
|
...resource,
|
|
1974
2556
|
destroy: () => {
|
|
2557
|
+
// queue first — a late queue flush must not poke an already-destroyed resource
|
|
2558
|
+
queueRef.destroy();
|
|
1975
2559
|
statusSub.unsubscribe();
|
|
1976
2560
|
resource.destroy();
|
|
1977
|
-
queueRef.destroy();
|
|
1978
2561
|
},
|
|
1979
2562
|
mutate: (value, ictx) => {
|
|
1980
2563
|
if (shouldQueue) {
|
|
1981
2564
|
return queue.update((q) => [...q, [value, ictx]]);
|
|
1982
2565
|
}
|
|
1983
2566
|
else {
|
|
2567
|
+
// latest-wins: a mutation already in flight gets superseded (its request is
|
|
2568
|
+
// aborted by the request change), so its onSuccess/onError will never fire —
|
|
2569
|
+
// settle its context NOW so optimistic state can be rolled back/cleaned up
|
|
2570
|
+
if (untracked(next) !== NULL_VALUE) {
|
|
2571
|
+
if (isDevMode())
|
|
2572
|
+
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.');
|
|
2573
|
+
try {
|
|
2574
|
+
onSettled?.(ctx);
|
|
2575
|
+
}
|
|
2576
|
+
catch (settleErr) {
|
|
2577
|
+
if (isDevMode())
|
|
2578
|
+
console.error('[@mmstack/resource]: error thrown in onSettled hook for a superseded mutation', settleErr);
|
|
2579
|
+
}
|
|
2580
|
+
ctx = undefined;
|
|
2581
|
+
}
|
|
1984
2582
|
try {
|
|
1985
2583
|
ctx = onMutate?.(value, ictx);
|
|
1986
2584
|
next.set(value);
|
|
@@ -2008,5 +2606,5 @@ function mutationResource(request, options0 = {}) {
|
|
|
2008
2606
|
* Generated bundle index. Do not edit.
|
|
2009
2607
|
*/
|
|
2010
2608
|
|
|
2011
|
-
export { Cache, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
|
|
2609
|
+
export { Cache, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, hashRequest, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
|
|
2012
2610
|
//# sourceMappingURL=mmstack-resource.mjs.map
|