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