@mmstack/resource 19.6.0 → 19.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -20
- package/fesm2022/mmstack-resource.mjs +691 -189
- package/fesm2022/mmstack-resource.mjs.map +1 -1
- package/index.d.ts +1 -0
- package/lib/infinite-query.d.ts +81 -0
- package/lib/mutation-resource.d.ts +20 -1
- package/lib/options.d.ts +5 -6
- package/lib/query-resource.d.ts +47 -5
- package/lib/util/cache/cache-interceptor.d.ts +4 -0
- package/lib/util/cache/cache.d.ts +60 -6
- package/lib/util/cache/index.d.ts +1 -1
- package/lib/util/cache/public_api.d.ts +1 -1
- package/lib/util/dedupe-interceptor.d.ts +6 -0
- package/lib/util/hash-request.d.ts +9 -2
- package/lib/util/refresh.d.ts +31 -3
- package/lib/util/sensors.d.ts +2 -0
- package/lib/util/share-pending.d.ts +12 -0
- package/package.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { InjectionToken, inject, runInInjectionContext, DestroyRef, isDevMode,
|
|
3
|
-
import { injectTransitionScope, mutable, toWritable, keepPrevious, sensor, nestedEffect } from '@mmstack/primitives';
|
|
2
|
+
import { InjectionToken, inject, runInInjectionContext, DestroyRef, isDevMode, signal, computed, untracked, PLATFORM_ID, effect, Injector, ResourceStatus, Injectable, linkedSignal } from '@angular/core';
|
|
4
3
|
import { HttpHeaders, HttpResponse, HttpParams, HttpContextToken, HttpContext, httpResource, HttpClient } from '@angular/common/http';
|
|
5
|
-
import {
|
|
4
|
+
import { injectTransitionScope, mutable, toWritable, keepPrevious, sensor, 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
|
const RESOURCE_OPTIONS = new InjectionToken('@mmstack/resource:resource-options', { factory: () => ({}) });
|
|
@@ -30,7 +30,7 @@ function provideTypedResourceOptions(token, valueOrFn) {
|
|
|
30
30
|
function applyResourceRegistration(ref, register, injector) {
|
|
31
31
|
if (!register)
|
|
32
32
|
return;
|
|
33
|
-
const opt =
|
|
33
|
+
const opt = { suspends: register === 'suspend' };
|
|
34
34
|
const run = injector
|
|
35
35
|
? (fn) => runInInjectionContext(injector, fn)
|
|
36
36
|
: (fn) => fn();
|
|
@@ -62,6 +62,8 @@ function toCacheDB(db, storeName) {
|
|
|
62
62
|
const request = store.getAll();
|
|
63
63
|
request.onsuccess = () => res(request.result);
|
|
64
64
|
request.onerror = () => rej(request.error);
|
|
65
|
+
// some browsers abort (rather than error) e.g. on quota issues — without this the promise would stay pending forever
|
|
66
|
+
transaction.onabort = () => rej(transaction.error);
|
|
65
67
|
})
|
|
66
68
|
.then((entries) => entries.filter((e) => e.expiresAt > now))
|
|
67
69
|
.catch((err) => {
|
|
@@ -77,6 +79,8 @@ function toCacheDB(db, storeName) {
|
|
|
77
79
|
store.put(value);
|
|
78
80
|
transaction.oncomplete = () => res();
|
|
79
81
|
transaction.onerror = () => rej(transaction.error);
|
|
82
|
+
// QuotaExceededError surfaces as an abort in some browsers
|
|
83
|
+
transaction.onabort = () => rej(transaction.error);
|
|
80
84
|
}).catch((err) => {
|
|
81
85
|
if (isDevMode())
|
|
82
86
|
console.error('Error storing item in cache DB:', err);
|
|
@@ -89,6 +93,7 @@ function toCacheDB(db, storeName) {
|
|
|
89
93
|
store.delete(key);
|
|
90
94
|
transaction.oncomplete = () => res();
|
|
91
95
|
transaction.onerror = () => rej(transaction.error);
|
|
96
|
+
transaction.onabort = () => rej(transaction.error);
|
|
92
97
|
}).catch((err) => {
|
|
93
98
|
if (isDevMode())
|
|
94
99
|
console.error('Error removing item from cache DB:', err);
|
|
@@ -105,8 +110,10 @@ function createSingleStoreDB(name, getStoreName, version = 1) {
|
|
|
105
110
|
if (!globalThis.indexedDB)
|
|
106
111
|
return Promise.resolve(createNoopDB());
|
|
107
112
|
return new Promise((res, rej) => {
|
|
108
|
-
if (version < 1)
|
|
113
|
+
if (version < 1) {
|
|
109
114
|
rej(new Error('Version must be 1 or greater'));
|
|
115
|
+
return; // rej does not stop execution — without this, indexedDB.open(name, 0) still runs
|
|
116
|
+
}
|
|
110
117
|
const req = indexedDB.open(name, version);
|
|
111
118
|
req.onupgradeneeded = (event) => {
|
|
112
119
|
const db = req.result;
|
|
@@ -141,6 +148,13 @@ function isSyncMessage(msg) {
|
|
|
141
148
|
'type' in msg &&
|
|
142
149
|
msg.type === 'cache-sync-message');
|
|
143
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* setTimeout coerces its delay through a signed 32-bit conversion: `Infinity` becomes 0
|
|
153
|
+
* (immediate!) and anything above 2^31-1 ms (~24.8 days) wraps negative. Entries beyond
|
|
154
|
+
* this bound get NO timer and rely on lazy expiry (`expiresAt <= now` checks) plus the
|
|
155
|
+
* periodic sweep instead.
|
|
156
|
+
*/
|
|
157
|
+
const MAX_TIMER_DELAY = 2 ** 31 - 1;
|
|
144
158
|
const ONE_DAY = 1000 * 60 * 60 * 24;
|
|
145
159
|
const ONE_HOUR = 1000 * 60 * 60;
|
|
146
160
|
const DEFAULT_CLEANUP_OPT = {
|
|
@@ -160,9 +174,27 @@ class Cache {
|
|
|
160
174
|
internal = mutable(new Map());
|
|
161
175
|
cleanupOpt;
|
|
162
176
|
id = generateID();
|
|
177
|
+
/** True once async hydration from the persistence layer has completed (or was empty). */
|
|
178
|
+
hydrated = false;
|
|
179
|
+
/** Keys invalidated while hydration was still in flight — must not be resurrected by it. */
|
|
180
|
+
hydrationTombstones = new Set();
|
|
181
|
+
hitCount = signal(0);
|
|
182
|
+
missCount = signal(0);
|
|
183
|
+
/**
|
|
184
|
+
* Read-only cache statistics for debugging/observability — entry count plus
|
|
185
|
+
* request-level hit/miss counters (counted on direct lookups, e.g. the cache
|
|
186
|
+
* interceptor's, not on every reactive signal read). Render it in a debug
|
|
187
|
+
* panel; it intentionally exposes no way to mutate the cache.
|
|
188
|
+
*/
|
|
189
|
+
stats = computed(() => ({
|
|
190
|
+
size: this.internal().size,
|
|
191
|
+
hits: this.hitCount(),
|
|
192
|
+
misses: this.missCount(),
|
|
193
|
+
}));
|
|
163
194
|
/**
|
|
164
|
-
* Destroys the cache instance,
|
|
165
|
-
*
|
|
195
|
+
* Destroys the cache instance, clearing the cleanup interval and closing the
|
|
196
|
+
* cross-tab channel. Called automatically when the providing injector is destroyed
|
|
197
|
+
* (wired up by `provideQueryCache`); call it manually for caches you construct yourself.
|
|
166
198
|
*/
|
|
167
199
|
destroy;
|
|
168
200
|
broadcast = () => {
|
|
@@ -179,11 +211,7 @@ class Cache {
|
|
|
179
211
|
* @param syncTabs - If provided, the cache will use the options a BroadcastChannel to send updates between tabs.
|
|
180
212
|
* Defaults to `undefined`, meaning no synchronization across tabs.
|
|
181
213
|
*/
|
|
182
|
-
constructor(ttl = ONE_DAY, staleTime = ONE_HOUR, cleanupOpt = {
|
|
183
|
-
type: 'lru',
|
|
184
|
-
maxSize: 1000,
|
|
185
|
-
checkInterval: ONE_HOUR,
|
|
186
|
-
}, syncTabs, db = Promise.resolve(createNoopDB())) {
|
|
214
|
+
constructor(ttl = ONE_DAY, staleTime = ONE_HOUR, cleanupOpt = DEFAULT_CLEANUP_OPT, syncTabs, db = Promise.resolve(createNoopDB())) {
|
|
187
215
|
this.ttl = ttl;
|
|
188
216
|
this.staleTime = staleTime;
|
|
189
217
|
this.db = db;
|
|
@@ -193,10 +221,12 @@ class Cache {
|
|
|
193
221
|
};
|
|
194
222
|
if (this.cleanupOpt.maxSize <= 0)
|
|
195
223
|
throw new Error('maxSize must be greater than 0');
|
|
196
|
-
//
|
|
197
|
-
const cleanupInterval =
|
|
198
|
-
|
|
199
|
-
|
|
224
|
+
// a non-finite checkInterval disables the sweeper entirely (used by the shared NoopCache)
|
|
225
|
+
const cleanupInterval = Number.isFinite(this.cleanupOpt.checkInterval)
|
|
226
|
+
? setInterval(() => {
|
|
227
|
+
this.cleanup();
|
|
228
|
+
}, this.cleanupOpt.checkInterval)
|
|
229
|
+
: undefined;
|
|
200
230
|
let destroySyncTabs = () => {
|
|
201
231
|
// noop
|
|
202
232
|
};
|
|
@@ -230,13 +260,11 @@ class Cache {
|
|
|
230
260
|
const value = syncTabs.deserialize(msg.entry.value);
|
|
231
261
|
if (value === null)
|
|
232
262
|
return;
|
|
233
|
-
// Last-write-wins by `updated` timestamp.
|
|
234
|
-
// written more recently than the broadcast we just received, the
|
|
235
|
-
// broadcast is stale (in-flight when we wrote locally) — drop it.
|
|
263
|
+
// Last-write-wins by `updated` timestamp.
|
|
236
264
|
const existing = untracked(this.internal).get(msg.entry.key);
|
|
237
265
|
if (existing && existing.updated >= msg.entry.updated)
|
|
238
266
|
return;
|
|
239
|
-
this.
|
|
267
|
+
this.restoreInternal({ ...msg.entry, value });
|
|
240
268
|
}
|
|
241
269
|
else if (msg.action === 'invalidate') {
|
|
242
270
|
this.invalidateInternal(msg.entry.key, true);
|
|
@@ -251,7 +279,8 @@ class Cache {
|
|
|
251
279
|
if (destroyed)
|
|
252
280
|
return;
|
|
253
281
|
destroyed = true;
|
|
254
|
-
|
|
282
|
+
if (cleanupInterval !== undefined)
|
|
283
|
+
clearInterval(cleanupInterval);
|
|
255
284
|
destroySyncTabs();
|
|
256
285
|
};
|
|
257
286
|
this.db
|
|
@@ -263,22 +292,19 @@ class Cache {
|
|
|
263
292
|
.then((entries) => {
|
|
264
293
|
if (destroyed)
|
|
265
294
|
return;
|
|
266
|
-
// load entries into the cache
|
|
267
295
|
const current = untracked(this.internal);
|
|
268
296
|
entries.forEach((entry) => {
|
|
269
297
|
if (current.has(entry.key))
|
|
270
298
|
return;
|
|
271
|
-
|
|
299
|
+
// a key invalidated while hydration was in flight must stay dead
|
|
300
|
+
if (this.hydrationTombstones.has(entry.key))
|
|
301
|
+
return;
|
|
302
|
+
this.restoreInternal(entry);
|
|
272
303
|
});
|
|
304
|
+
this.hydrated = true;
|
|
305
|
+
this.hydrationTombstones.clear();
|
|
273
306
|
});
|
|
274
307
|
this.destroy = destroy;
|
|
275
|
-
// 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
|
|
276
|
-
const registry = new FinalizationRegistry((id) => {
|
|
277
|
-
if (id === this.id) {
|
|
278
|
-
destroy();
|
|
279
|
-
}
|
|
280
|
-
});
|
|
281
|
-
registry.register(this, this.id);
|
|
282
308
|
}
|
|
283
309
|
/** @internal */
|
|
284
310
|
getInternal(key) {
|
|
@@ -291,22 +317,45 @@ class Cache {
|
|
|
291
317
|
const now = Date.now();
|
|
292
318
|
if (!found || found.expiresAt <= now)
|
|
293
319
|
return null;
|
|
294
|
-
found.useCount++;
|
|
295
320
|
return {
|
|
296
321
|
...found,
|
|
297
322
|
isStale: found.stale <= now,
|
|
298
323
|
};
|
|
324
|
+
}, {
|
|
325
|
+
equal: (a, b) => a === b ||
|
|
326
|
+
(!!a &&
|
|
327
|
+
!!b &&
|
|
328
|
+
a.key === b.key &&
|
|
329
|
+
a.value === b.value &&
|
|
330
|
+
a.updated === b.updated &&
|
|
331
|
+
a.isStale === b.isStale),
|
|
299
332
|
});
|
|
300
333
|
}
|
|
334
|
+
/** @internal Imperative access bookkeeping for LRU eviction. */
|
|
335
|
+
touch(entry) {
|
|
336
|
+
entry.lastAccessed = Date.now();
|
|
337
|
+
entry.useCount++;
|
|
338
|
+
}
|
|
301
339
|
/**
|
|
302
|
-
* Retrieves a cache entry
|
|
303
|
-
* for
|
|
340
|
+
* Retrieves a cache entry directly (non-reactively), updating its access bookkeeping
|
|
341
|
+
* for LRU eviction.
|
|
304
342
|
* @internal
|
|
305
343
|
* @param key - The key of the entry to retrieve.
|
|
306
344
|
* @returns The cache entry, or `null` if not found or expired.
|
|
307
345
|
*/
|
|
308
346
|
getUntracked(key) {
|
|
309
|
-
|
|
347
|
+
const found = untracked(this.internal).get(key);
|
|
348
|
+
const now = Date.now();
|
|
349
|
+
if (!found || found.expiresAt <= now) {
|
|
350
|
+
this.missCount.update((c) => c + 1);
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
this.touch(found);
|
|
354
|
+
this.hitCount.update((c) => c + 1);
|
|
355
|
+
return {
|
|
356
|
+
...found,
|
|
357
|
+
isStale: found.stale <= now,
|
|
358
|
+
};
|
|
310
359
|
}
|
|
311
360
|
/**
|
|
312
361
|
* Retrieves a cache entry as a signal.
|
|
@@ -332,38 +381,65 @@ class Cache {
|
|
|
332
381
|
/**
|
|
333
382
|
* Stores a value in the cache.
|
|
334
383
|
*
|
|
384
|
+
* NOTE: cached values are shared by reference across all consumers (current and
|
|
385
|
+
* future cache hits, persistence, cross-tab sync) — do not mutate a value after
|
|
386
|
+
* storing it or after reading it from the cache.
|
|
387
|
+
*
|
|
335
388
|
* @param key - The key under which to store the value.
|
|
336
389
|
* @param value - The value to store.
|
|
337
390
|
* @param staleTime - (Optional) The stale time for this entry, in milliseconds. Overrides the default `staleTime`.
|
|
338
391
|
* @param ttl - (Optional) The TTL for this entry, in milliseconds. Overrides the default `ttl`.
|
|
392
|
+
* @param persist - (Optional) Whether to also write the entry to the persistence layer (IndexedDB). Defaults to `false`.
|
|
339
393
|
*/
|
|
340
394
|
store(key, value, staleTime = this.staleTime, ttl = this.ttl, persist = false) {
|
|
341
395
|
this.storeInternal(key, value, staleTime, ttl, false, persist);
|
|
342
396
|
}
|
|
343
397
|
storeInternal(key, value, staleTime = this.staleTime, ttl = this.ttl, fromSync = false, persist = false) {
|
|
344
|
-
const entry = this.
|
|
345
|
-
if (entry) {
|
|
346
|
-
clearTimeout(entry.timeout); // stop invalidation
|
|
347
|
-
}
|
|
348
|
-
const prevCount = entry?.useCount ?? 0;
|
|
398
|
+
const entry = untracked(this.internal).get(key);
|
|
349
399
|
// ttl cannot be less than staleTime
|
|
350
400
|
if (ttl < staleTime)
|
|
351
401
|
staleTime = ttl;
|
|
352
402
|
const now = Date.now();
|
|
353
|
-
|
|
403
|
+
this.setEntry({
|
|
354
404
|
value,
|
|
355
405
|
created: entry?.created ?? now,
|
|
356
406
|
updated: now,
|
|
357
|
-
useCount:
|
|
407
|
+
useCount: (entry?.useCount ?? 0) + 1,
|
|
408
|
+
lastAccessed: now,
|
|
358
409
|
stale: now + staleTime,
|
|
359
410
|
expiresAt: now + ttl,
|
|
360
411
|
key,
|
|
361
|
-
};
|
|
412
|
+
}, fromSync, persist);
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* @internal
|
|
416
|
+
* Inserts an entry that already carries ABSOLUTE timestamps — hydration from the
|
|
417
|
+
* persistence layer and cross-tab sync messages. Never re-anchors freshness to
|
|
418
|
+
* `Date.now()`, never persists, never broadcasts.
|
|
419
|
+
*/
|
|
420
|
+
restoreInternal(entry) {
|
|
421
|
+
this.setEntry({
|
|
422
|
+
...entry,
|
|
423
|
+
// rows persisted by older versions may lack the field
|
|
424
|
+
lastAccessed: entry.lastAccessed ?? entry.updated,
|
|
425
|
+
}, true, false);
|
|
426
|
+
}
|
|
427
|
+
/** @internal Shared writer: arms the expiry timer only within the safe delay range. */
|
|
428
|
+
setEntry(next, fromSync, persist) {
|
|
429
|
+
const existing = untracked(this.internal).get(next.key);
|
|
430
|
+
if (existing)
|
|
431
|
+
clearTimeout(existing.timeout); // stop the previous invalidation
|
|
432
|
+
const remaining = next.expiresAt - Date.now();
|
|
433
|
+
// already expired (clock skew on a synced/restored entry) — don't insert
|
|
434
|
+
if (remaining <= 0)
|
|
435
|
+
return;
|
|
436
|
+
// Infinity (immutable) or > 2^31-1 would coerce to an IMMEDIATE timeout — such
|
|
437
|
+
// entries get no timer and rely on lazy expiry + the periodic sweep instead
|
|
438
|
+
const timeout = Number.isFinite(remaining) && remaining <= MAX_TIMER_DELAY
|
|
439
|
+
? setTimeout(() => this.invalidate(next.key), remaining)
|
|
440
|
+
: undefined;
|
|
362
441
|
this.internal.mutate((map) => {
|
|
363
|
-
map.set(key, {
|
|
364
|
-
...next,
|
|
365
|
-
timeout: setTimeout(() => this.invalidate(key), ttl),
|
|
366
|
-
});
|
|
442
|
+
map.set(next.key, { ...next, timeout });
|
|
367
443
|
return map;
|
|
368
444
|
});
|
|
369
445
|
if (!fromSync) {
|
|
@@ -409,32 +485,55 @@ class Cache {
|
|
|
409
485
|
return keys.length;
|
|
410
486
|
}
|
|
411
487
|
invalidateInternal(key, fromSync = false) {
|
|
412
|
-
|
|
413
|
-
if (!
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
488
|
+
// a key invalidated before async hydration completes must not be resurrected by it
|
|
489
|
+
if (!this.hydrated)
|
|
490
|
+
this.hydrationTombstones.add(key);
|
|
491
|
+
const entry = untracked(this.internal).get(key);
|
|
492
|
+
if (entry) {
|
|
493
|
+
clearTimeout(entry.timeout);
|
|
494
|
+
this.internal.mutate((map) => {
|
|
495
|
+
map.delete(key);
|
|
496
|
+
return map;
|
|
497
|
+
});
|
|
498
|
+
}
|
|
420
499
|
if (!fromSync) {
|
|
421
500
|
this.db.then((db) => db.remove(key));
|
|
422
501
|
this.broadcast({ action: 'invalidate', entry: { key } });
|
|
423
502
|
}
|
|
424
503
|
}
|
|
425
|
-
/**
|
|
504
|
+
/**
|
|
505
|
+
* Removes EVERY entry — memory, persisted rows, and (via broadcast) other tabs.
|
|
506
|
+
* Call on logout/auth changes so no prior user's responses survive.
|
|
507
|
+
*/
|
|
508
|
+
clear() {
|
|
509
|
+
for (const key of Array.from(untracked(this.internal).keys())) {
|
|
510
|
+
this.invalidateInternal(key);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/** @internal Drops expired entries, then enforces `maxSize` by the configured strategy. */
|
|
426
514
|
cleanup() {
|
|
515
|
+
const now = Date.now();
|
|
516
|
+
// expired entries first — their timers may never have fired (throttled background
|
|
517
|
+
// tabs, or timer-less long-TTL entries)
|
|
518
|
+
const expired = Array.from(untracked(this.internal).entries()).filter(([, e]) => e.expiresAt <= now);
|
|
519
|
+
if (expired.length) {
|
|
520
|
+
expired.forEach(([, e]) => clearTimeout(e.timeout));
|
|
521
|
+
this.internal.mutate((map) => {
|
|
522
|
+
expired.forEach(([key]) => map.delete(key));
|
|
523
|
+
return map;
|
|
524
|
+
});
|
|
525
|
+
}
|
|
427
526
|
if (untracked(this.internal).size <= this.cleanupOpt.maxSize)
|
|
428
527
|
return;
|
|
429
528
|
const sorted = Array.from(untracked(this.internal).entries()).toSorted((a, b) => {
|
|
430
529
|
if (this.cleanupOpt.type === 'lru') {
|
|
431
|
-
return a[1].
|
|
530
|
+
return a[1].lastAccessed - b[1].lastAccessed; // least recently accessed first
|
|
432
531
|
}
|
|
433
532
|
else {
|
|
434
533
|
return a[1].created - b[1].created; // oldest first
|
|
435
534
|
}
|
|
436
535
|
});
|
|
437
|
-
const keepCount = Math.floor(this.cleanupOpt.maxSize / 2);
|
|
536
|
+
const keepCount = Math.max(1, Math.floor(this.cleanupOpt.maxSize / 2));
|
|
438
537
|
const removed = sorted.slice(0, sorted.length - keepCount);
|
|
439
538
|
const keep = sorted.slice(removed.length, sorted.length);
|
|
440
539
|
removed.forEach(([, e]) => {
|
|
@@ -479,7 +578,8 @@ function provideQueryCache(opt) {
|
|
|
479
578
|
return JSON.stringify({
|
|
480
579
|
body: value.body,
|
|
481
580
|
status: value.status,
|
|
482
|
-
statusText:
|
|
581
|
+
// statusText intentionally omitted: deprecated in Angular, meaningless under
|
|
582
|
+
// HTTP/2+ (HttpResponse defaults it to 'OK' on reconstruction)
|
|
483
583
|
headers: headerKeys.length > 0 ? headersRecord : undefined,
|
|
484
584
|
url: value.url,
|
|
485
585
|
});
|
|
@@ -495,7 +595,6 @@ function provideQueryCache(opt) {
|
|
|
495
595
|
return new HttpResponse({
|
|
496
596
|
body: parsed.body,
|
|
497
597
|
status: parsed.status,
|
|
498
|
-
statusText: parsed.statusText,
|
|
499
598
|
headers: headers,
|
|
500
599
|
url: parsed.url,
|
|
501
600
|
});
|
|
@@ -506,48 +605,72 @@ function provideQueryCache(opt) {
|
|
|
506
605
|
return null;
|
|
507
606
|
}
|
|
508
607
|
};
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
serialize,
|
|
513
|
-
deserialize,
|
|
514
|
-
}
|
|
515
|
-
: undefined;
|
|
516
|
-
const db = opt?.persist === false
|
|
517
|
-
? undefined
|
|
518
|
-
: createSingleStoreDB('mmstack-query-cache-db', (version) => `query-store_v${version}`, opt?.version).then((db) => {
|
|
519
|
-
return {
|
|
520
|
-
getAll: () => {
|
|
521
|
-
return db.getAll().then((entries) => {
|
|
522
|
-
return entries
|
|
523
|
-
.map((entry) => {
|
|
524
|
-
const value = deserialize(entry.value);
|
|
525
|
-
if (value === null)
|
|
526
|
-
return null;
|
|
527
|
-
return {
|
|
528
|
-
...entry,
|
|
529
|
-
value,
|
|
530
|
-
};
|
|
531
|
-
})
|
|
532
|
-
.filter((e) => e !== null);
|
|
533
|
-
});
|
|
534
|
-
},
|
|
535
|
-
store: (entry) => {
|
|
536
|
-
return db.store({ ...entry, value: serialize(entry.value) });
|
|
537
|
-
},
|
|
538
|
-
remove: db.remove,
|
|
539
|
-
};
|
|
540
|
-
});
|
|
608
|
+
// version-suffixed so two deploys with incompatible schemas in adjacent tabs don't
|
|
609
|
+
// push entries into each other's caches (the `version` option only fences IndexedDB)
|
|
610
|
+
const syncChannelId = `mmstack-query-cache-sync_v${opt?.version ?? 1}`;
|
|
541
611
|
return {
|
|
542
612
|
provide: CLIENT_CACHE_TOKEN,
|
|
543
|
-
|
|
613
|
+
useFactory: () => {
|
|
614
|
+
const onServer = inject(PLATFORM_ID) === 'server';
|
|
615
|
+
// no IndexedDB / BroadcastChannel on the server — each request gets an
|
|
616
|
+
// isolated, request-lived, memory-only cache
|
|
617
|
+
const syncTabsOpt = !onServer && opt?.syncTabs
|
|
618
|
+
? {
|
|
619
|
+
id: syncChannelId,
|
|
620
|
+
serialize,
|
|
621
|
+
deserialize,
|
|
622
|
+
}
|
|
623
|
+
: undefined;
|
|
624
|
+
const db = onServer || opt?.persist === false
|
|
625
|
+
? undefined
|
|
626
|
+
: createSingleStoreDB('mmstack-query-cache-db', (version) => `query-store_v${version}`, opt?.version).then((db) => {
|
|
627
|
+
return {
|
|
628
|
+
getAll: () => {
|
|
629
|
+
return db.getAll().then((entries) => {
|
|
630
|
+
return entries
|
|
631
|
+
.map((entry) => {
|
|
632
|
+
const value = deserialize(entry.value);
|
|
633
|
+
if (value === null)
|
|
634
|
+
return null;
|
|
635
|
+
return {
|
|
636
|
+
...entry,
|
|
637
|
+
value,
|
|
638
|
+
};
|
|
639
|
+
})
|
|
640
|
+
.filter((e) => e !== null);
|
|
641
|
+
});
|
|
642
|
+
},
|
|
643
|
+
store: (entry) => {
|
|
644
|
+
return db.store({ ...entry, value: serialize(entry.value) });
|
|
645
|
+
},
|
|
646
|
+
remove: db.remove,
|
|
647
|
+
};
|
|
648
|
+
});
|
|
649
|
+
const cache = new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup, syncTabsOpt, db);
|
|
650
|
+
// release the sweep interval / channel with the providing injector
|
|
651
|
+
inject(DestroyRef, { optional: true })?.onDestroy(() => cache.destroy());
|
|
652
|
+
return cache;
|
|
653
|
+
},
|
|
544
654
|
};
|
|
545
655
|
}
|
|
546
656
|
class NoopCache extends Cache {
|
|
657
|
+
constructor() {
|
|
658
|
+
// Infinity checkInterval → no sweep interval is ever armed, so the shared
|
|
659
|
+
// instance below never pins a timer
|
|
660
|
+
super(undefined, undefined, {
|
|
661
|
+
type: 'lru',
|
|
662
|
+
maxSize: 200,
|
|
663
|
+
checkInterval: Infinity,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
547
667
|
store(_, __, ___ = super.staleTime, ____ = super.ttl) {
|
|
548
668
|
// noop
|
|
549
669
|
}
|
|
550
670
|
}
|
|
671
|
+
// one shared instance — minting a NoopCache per injectQueryCache() miss would leak
|
|
672
|
+
// an instance (and previously an interval) on every prod call without a provider
|
|
673
|
+
let NOOP_CACHE;
|
|
551
674
|
/**
|
|
552
675
|
* Injects the `QueryCache` instance that is used within queryResource.
|
|
553
676
|
* Allows for direct modification of cached data, but is mostly meant for internal use.
|
|
@@ -582,10 +705,21 @@ function injectQueryCache(injector) {
|
|
|
582
705
|
if (isDevMode())
|
|
583
706
|
throw new Error('Cache not provided, please add provideQueryCache() to providers array');
|
|
584
707
|
else
|
|
585
|
-
return new NoopCache();
|
|
708
|
+
return (NOOP_CACHE ??= new NoopCache());
|
|
586
709
|
}
|
|
587
710
|
return cache;
|
|
588
711
|
}
|
|
712
|
+
/**
|
|
713
|
+
* Injects the cache statistics, including the current size of the cache and the number of hits and misses.
|
|
714
|
+
*
|
|
715
|
+
* @param injector - (Optional) The injector to use. If not provided, the current
|
|
716
|
+
* injection context is used.
|
|
717
|
+
* @returns A signal containing the cache statistics.
|
|
718
|
+
*/
|
|
719
|
+
function injectCacheStats(injector) {
|
|
720
|
+
const cache = injectQueryCache(injector);
|
|
721
|
+
return cache.stats;
|
|
722
|
+
}
|
|
589
723
|
|
|
590
724
|
/**
|
|
591
725
|
* Returns `true` for any object-like value whose own enumerable keys should
|
|
@@ -689,6 +823,76 @@ function hash(...args) {
|
|
|
689
823
|
return hashKey(args);
|
|
690
824
|
}
|
|
691
825
|
|
|
826
|
+
/**
|
|
827
|
+
* @internal
|
|
828
|
+
* One-way ~64-bit digest from two independent FNV-1a passes. Used for header VALUES in
|
|
829
|
+
* cache keys: keys are persisted (IndexedDB) and broadcast cross-tab, so raw values
|
|
830
|
+
* (auth tokens!) must never appear in them. A single 32-bit digest's 2^-32 collision
|
|
831
|
+
* chance is too thin at a security boundary — two colliding tokens would serve one
|
|
832
|
+
* user's cached data under another user's key; 64 bits puts collisions out of reach.
|
|
833
|
+
* High-entropy secrets are not recoverable from the digest.
|
|
834
|
+
*/
|
|
835
|
+
function digestHeaderValue(value) {
|
|
836
|
+
let h1 = 0x811c9dc5; // FNV-1a offset basis
|
|
837
|
+
let h2 = 0xcbf29ce4; // independent second pass
|
|
838
|
+
for (let i = 0; i < value.length; i++) {
|
|
839
|
+
const c = value.charCodeAt(i);
|
|
840
|
+
h1 = Math.imul(h1 ^ c, 0x01000193); // FNV prime
|
|
841
|
+
h2 = Math.imul(h2 ^ c, 0x01000197); // distinct odd multiplier
|
|
842
|
+
}
|
|
843
|
+
return ((h1 >>> 0).toString(16).padStart(8, '0') +
|
|
844
|
+
(h2 >>> 0).toString(16).padStart(8, '0'));
|
|
845
|
+
}
|
|
846
|
+
function readHeader(headers, name) {
|
|
847
|
+
if (!headers)
|
|
848
|
+
return null;
|
|
849
|
+
if (headers instanceof HttpHeaders) {
|
|
850
|
+
const all = headers.getAll(name);
|
|
851
|
+
return all && all.length ? all.join(',') : null;
|
|
852
|
+
}
|
|
853
|
+
// record form — header names are case-insensitive
|
|
854
|
+
const lower = name.toLowerCase();
|
|
855
|
+
for (const key of Object.keys(headers)) {
|
|
856
|
+
if (key.toLowerCase() !== lower)
|
|
857
|
+
continue;
|
|
858
|
+
const value = headers[key];
|
|
859
|
+
if (value == null)
|
|
860
|
+
return null;
|
|
861
|
+
return Array.isArray(value) ? value.join(',') : String(value);
|
|
862
|
+
}
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Content-negotiation headers whose values are low-entropy and non-identifying —
|
|
867
|
+
* embedded (URI-encoded) raw, keeping keys human-readable and skipping the digest.
|
|
868
|
+
* Anything NOT on this list (Authorization, api keys, tenant/x-* headers — we can't
|
|
869
|
+
* know what they carry) is one-way digested instead.
|
|
870
|
+
*/
|
|
871
|
+
const SAFE_RAW_HEADERS = new Set([
|
|
872
|
+
'accept',
|
|
873
|
+
'accept-language',
|
|
874
|
+
'content-language',
|
|
875
|
+
'content-type',
|
|
876
|
+
]);
|
|
877
|
+
function normalizeVaryHeaders(headers, names) {
|
|
878
|
+
return names
|
|
879
|
+
.map((n) => n.toLowerCase())
|
|
880
|
+
.toSorted()
|
|
881
|
+
.map((name) => {
|
|
882
|
+
if (isDevMode() && (name === 'cookie' || name === 'set-cookie')) {
|
|
883
|
+
console.warn(`[@mmstack/resource]: varyHeaders includes '${name}'. Browser-attached cookies never appear on the request object (so this usually partitions nothing), and manually-set cookie values often rotate per-request (shredding the hit rate). The header IS still honored (digested) — but prefer varying on 'Authorization' or a tenant header.`);
|
|
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
|
+
}
|
|
692
896
|
function normalizeParams(params) {
|
|
693
897
|
const p = params instanceof HttpParams ? params : new HttpParams({ fromObject: params });
|
|
694
898
|
return p
|
|
@@ -733,20 +937,48 @@ function hashBody(body) {
|
|
|
733
937
|
* Builds a stable cache/dedupe key from an HTTP request shape (accepts both
|
|
734
938
|
* `HttpRequest` and `HttpResourceRequest`).
|
|
735
939
|
*
|
|
736
|
-
* Key composition: `${method}:${url}:${responseType}[:${params}][:${body}]`
|
|
940
|
+
* Key composition: `${method}:${url}:${responseType}[:${params}][:${body}][:${vary}]`
|
|
737
941
|
* - `method` defaults to `'GET'`, `responseType` to `'json'` (Angular defaults).
|
|
738
942
|
* - Query params are sorted alphabetically and URL-encoded for stability.
|
|
739
943
|
* - Body hashing handles `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`
|
|
740
944
|
* and typed arrays explicitly; everything else flows through key-sorted
|
|
741
945
|
* `JSON.stringify` via `hash()`.
|
|
946
|
+
* - `varyHeaders` (opt-in) mixes the named request headers into the key so responses
|
|
947
|
+
* that differ per header (e.g. `Authorization` → per-user, `Accept-Language`) get
|
|
948
|
+
* separate entries. Known-safe content-negotiation headers (`Accept`,
|
|
949
|
+
* `Accept-Language`, `Content-Language`, `Content-Type`) embed their value raw for
|
|
950
|
+
* readable keys; all other header VALUES are one-way digested, never embedded raw —
|
|
951
|
+
* keys are persisted to IndexedDB and broadcast across tabs.
|
|
742
952
|
*/
|
|
743
|
-
function hashRequest(req) {
|
|
953
|
+
function hashRequest(req, varyHeaders) {
|
|
744
954
|
const method = req.method ?? 'GET';
|
|
745
955
|
const responseType = req.responseType ?? 'json';
|
|
746
956
|
const base = `${method}:${req.url}:${responseType}`;
|
|
747
957
|
const params = req.params ? `:${normalizeParams(req.params)}` : '';
|
|
748
958
|
const body = req.body != null ? `:${hashBody(req.body)}` : '';
|
|
749
|
-
|
|
959
|
+
const vary = varyHeaders?.length
|
|
960
|
+
? `:vary(${normalizeVaryHeaders(req.headers, varyHeaders)})`
|
|
961
|
+
: '';
|
|
962
|
+
return base + params + body + vary;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* @internal
|
|
967
|
+
* Single-flight sharing: if a pending observable is already registered under `key`,
|
|
968
|
+
* return it; otherwise create one, share it (replaying the latest event to late
|
|
969
|
+
* subscribers), and deregister it on teardown/settle.
|
|
970
|
+
*
|
|
971
|
+
* Used by both the dedupe interceptor (keyed by full request hash, app-wide) and the
|
|
972
|
+
* cache interceptor (keyed by the CACHE key, guarding the miss/stale-revalidation path)
|
|
973
|
+
* — same mechanism, different keying/scope, so it lives here exactly once.
|
|
974
|
+
*/
|
|
975
|
+
function sharePending(pending, key, create) {
|
|
976
|
+
const existing = pending.get(key);
|
|
977
|
+
if (existing)
|
|
978
|
+
return existing;
|
|
979
|
+
const shared = create().pipe(finalize(() => pending.delete(key)), shareReplay({ bufferSize: 1, refCount: true }));
|
|
980
|
+
pending.set(key, shared);
|
|
981
|
+
return shared;
|
|
750
982
|
}
|
|
751
983
|
|
|
752
984
|
const CACHE_CONTEXT = new HttpContextToken(() => ({
|
|
@@ -766,6 +998,7 @@ function parseCacheControlHeader(req) {
|
|
|
766
998
|
noCache: false,
|
|
767
999
|
mustRevalidate: false,
|
|
768
1000
|
immutable: false,
|
|
1001
|
+
isPrivate: false,
|
|
769
1002
|
maxAge: null,
|
|
770
1003
|
staleWhileRevalidate: null,
|
|
771
1004
|
};
|
|
@@ -789,6 +1022,9 @@ function parseCacheControlHeader(req) {
|
|
|
789
1022
|
case 'immutable':
|
|
790
1023
|
directives.immutable = true;
|
|
791
1024
|
break;
|
|
1025
|
+
case 'private':
|
|
1026
|
+
directives.isPrivate = true;
|
|
1027
|
+
break;
|
|
792
1028
|
case 'max-age': {
|
|
793
1029
|
if (!value)
|
|
794
1030
|
break;
|
|
@@ -797,7 +1033,7 @@ function parseCacheControlHeader(req) {
|
|
|
797
1033
|
directives.maxAge = parsedValue;
|
|
798
1034
|
break;
|
|
799
1035
|
}
|
|
800
|
-
case 's-
|
|
1036
|
+
case 's-maxage': {
|
|
801
1037
|
if (!value)
|
|
802
1038
|
break;
|
|
803
1039
|
const parsedValue = parseInt(value, 10);
|
|
@@ -815,7 +1051,7 @@ function parseCacheControlHeader(req) {
|
|
|
815
1051
|
}
|
|
816
1052
|
}
|
|
817
1053
|
}
|
|
818
|
-
// s-
|
|
1054
|
+
// s-maxage takes precedence over max-age
|
|
819
1055
|
if (sMaxAge !== null)
|
|
820
1056
|
directives.maxAge = sMaxAge;
|
|
821
1057
|
// if no store nothing else is relevant
|
|
@@ -825,6 +1061,7 @@ function parseCacheControlHeader(req) {
|
|
|
825
1061
|
noCache: false,
|
|
826
1062
|
mustRevalidate: false,
|
|
827
1063
|
immutable: false,
|
|
1064
|
+
isPrivate: directives.isPrivate,
|
|
828
1065
|
maxAge: null,
|
|
829
1066
|
staleWhileRevalidate: null,
|
|
830
1067
|
};
|
|
@@ -844,14 +1081,32 @@ function resolveTimings(cacheControl, optStaleTime, optTTL) {
|
|
|
844
1081
|
staleTime: Infinity,
|
|
845
1082
|
ttl: Infinity,
|
|
846
1083
|
};
|
|
847
|
-
if (cacheControl.maxAge !== null)
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
1084
|
+
if (cacheControl.maxAge !== null) {
|
|
1085
|
+
staleTime = cacheControl.maxAge * 1000;
|
|
1086
|
+
if (cacheControl.staleWhileRevalidate !== null) {
|
|
1087
|
+
ttl = staleTime + cacheControl.staleWhileRevalidate * 1000;
|
|
1088
|
+
}
|
|
1089
|
+
else if (ttl !== undefined) {
|
|
1090
|
+
// a configured total lifetime must never undercut the server's fresh window
|
|
1091
|
+
ttl = Math.max(ttl, staleTime);
|
|
1092
|
+
}
|
|
1093
|
+
// no swr + no configured ttl → leave undefined so the cache's default ttl applies
|
|
1094
|
+
// (the entry stays resident past max-age for ETag revalidation)
|
|
1095
|
+
}
|
|
1096
|
+
else if (cacheControl.staleWhileRevalidate !== null) {
|
|
1097
|
+
// swr without max-age: stale immediately, revalidatable for the window
|
|
1098
|
+
staleTime = 0;
|
|
1099
|
+
ttl = cacheControl.staleWhileRevalidate * 1000;
|
|
1100
|
+
}
|
|
1101
|
+
// if no-cache is set, we must always revalidate (the entry stays usable for conditional requests until ttl)
|
|
852
1102
|
if (cacheControl.noCache || cacheControl.mustRevalidate)
|
|
853
1103
|
staleTime = 0;
|
|
854
|
-
|
|
1104
|
+
// option-only path (no server freshness): a misconfigured ttl < staleTime clamps the
|
|
1105
|
+
// fresh window down, mirroring the cache's own internal clamp
|
|
1106
|
+
if (cacheControl.maxAge === null &&
|
|
1107
|
+
ttl !== undefined &&
|
|
1108
|
+
staleTime !== undefined &&
|
|
1109
|
+
ttl < staleTime) {
|
|
855
1110
|
staleTime = ttl;
|
|
856
1111
|
}
|
|
857
1112
|
return { staleTime, ttl };
|
|
@@ -865,6 +1120,10 @@ function resolveTimings(cacheControl, optStaleTime, optTTL) {
|
|
|
865
1120
|
* is made to the server, and the response is cached according to the configured TTL and staleness.
|
|
866
1121
|
* The interceptor also respects `Cache-Control` headers from the server.
|
|
867
1122
|
*
|
|
1123
|
+
* Cache-enabled requests are single-flighted per cache key: N concurrent consumers of
|
|
1124
|
+
* the same missing/stale entry share ONE network request. Non-cached requests are not
|
|
1125
|
+
* touched — pair with `createDedupeRequestsInterceptor` to coalesce those as well.
|
|
1126
|
+
*
|
|
868
1127
|
* @param allowedMethods - An array of HTTP methods for which caching should be enabled.
|
|
869
1128
|
* Defaults to `['GET', 'HEAD', 'OPTIONS']`.
|
|
870
1129
|
*
|
|
@@ -885,7 +1144,10 @@ function resolveTimings(cacheControl, optStaleTime, optTTL) {
|
|
|
885
1144
|
*/
|
|
886
1145
|
function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
|
|
887
1146
|
const CACHE_METHODS = new Set(allowedMethods);
|
|
1147
|
+
const inFlight = new Map();
|
|
888
1148
|
return (req, next) => {
|
|
1149
|
+
if (inject(PLATFORM_ID) === 'server')
|
|
1150
|
+
return next(req);
|
|
889
1151
|
const cache = injectQueryCache();
|
|
890
1152
|
if (!CACHE_METHODS.has(req.method))
|
|
891
1153
|
return next(req);
|
|
@@ -898,60 +1160,78 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
|
|
|
898
1160
|
if (entry && !entry.isStale)
|
|
899
1161
|
return of(entry.value);
|
|
900
1162
|
// resource itself handles case of showing stale data...the request must process as this will "refresh said data"
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
if (opt.bustBrowserCache) {
|
|
910
|
-
req = req.clone({
|
|
911
|
-
setParams: { _cb: Date.now().toString() },
|
|
912
|
-
});
|
|
913
|
-
}
|
|
914
|
-
return next(req).pipe(tap((event) => {
|
|
915
|
-
if (!(event instanceof HttpResponse))
|
|
916
|
-
return;
|
|
917
|
-
if (event.ok) {
|
|
918
|
-
const cacheControl = parseCacheControlHeader(event);
|
|
919
|
-
if (cacheControl.noStore && !opt.ignoreCacheControl)
|
|
920
|
-
return;
|
|
921
|
-
const { staleTime, ttl } = opt.ignoreCacheControl
|
|
922
|
-
? opt
|
|
923
|
-
: resolveTimings(cacheControl, opt.staleTime, opt.ttl);
|
|
924
|
-
if (opt.ttl === 0)
|
|
925
|
-
return; // no point
|
|
926
|
-
const parsedResponse = opt.parse
|
|
927
|
-
? new HttpResponse({
|
|
928
|
-
body: opt.parse(event.body),
|
|
929
|
-
headers: event.headers,
|
|
930
|
-
status: event.status,
|
|
931
|
-
statusText: event.statusText,
|
|
932
|
-
url: event.url ?? undefined,
|
|
933
|
-
})
|
|
934
|
-
: event;
|
|
935
|
-
cache.store(key, parsedResponse, staleTime, ttl, opt.persist);
|
|
936
|
-
return;
|
|
1163
|
+
return sharePending(inFlight, key, () => {
|
|
1164
|
+
const eTag = entry?.value.headers.get('ETag');
|
|
1165
|
+
const lastModified = entry?.value.headers.get('Last-Modified');
|
|
1166
|
+
if (eTag) {
|
|
1167
|
+
req = req.clone({ setHeaders: { 'If-None-Match': eTag } });
|
|
1168
|
+
}
|
|
1169
|
+
if (lastModified) {
|
|
1170
|
+
req = req.clone({ setHeaders: { 'If-Modified-Since': lastModified } });
|
|
937
1171
|
}
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
const cacheControl = parseCacheControlHeader(event);
|
|
943
|
-
const { staleTime, ttl } = opt.ignoreCacheControl
|
|
944
|
-
? opt
|
|
945
|
-
: resolveTimings(cacheControl, opt.staleTime, opt.ttl);
|
|
946
|
-
cache.store(key, entry.value, staleTime, ttl, opt.persist);
|
|
1172
|
+
if (opt.bustBrowserCache) {
|
|
1173
|
+
req = req.clone({
|
|
1174
|
+
setParams: { _cb: Date.now().toString() },
|
|
1175
|
+
});
|
|
947
1176
|
}
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
if (
|
|
951
|
-
|
|
1177
|
+
// non-JSON bodies (blob/arraybuffer) cannot survive the JSON persistence layer
|
|
1178
|
+
const persistable = req.responseType === 'json';
|
|
1179
|
+
if (opt.persist && !persistable && isDevMode()) {
|
|
1180
|
+
console.warn(`[@mmstack/resource]: persist was requested for a '${req.responseType}' response — such bodies don't survive JSON serialization, persisting skipped.`);
|
|
952
1181
|
}
|
|
953
|
-
return event
|
|
954
|
-
|
|
1182
|
+
return next(req).pipe(tap((event) => {
|
|
1183
|
+
if (!(event instanceof HttpResponse))
|
|
1184
|
+
return;
|
|
1185
|
+
if (event.ok) {
|
|
1186
|
+
const cacheControl = parseCacheControlHeader(event);
|
|
1187
|
+
if (cacheControl.noStore && !opt.ignoreCacheControl)
|
|
1188
|
+
return;
|
|
1189
|
+
const { staleTime, ttl } = opt.ignoreCacheControl
|
|
1190
|
+
? opt
|
|
1191
|
+
: resolveTimings(cacheControl, opt.staleTime, opt.ttl);
|
|
1192
|
+
if (ttl === 0)
|
|
1193
|
+
return; // no point
|
|
1194
|
+
// `Cache-Control: private` → fine to keep in memory, never on disk
|
|
1195
|
+
const persist = (opt.persist ?? false) &&
|
|
1196
|
+
persistable &&
|
|
1197
|
+
(opt.ignoreCacheControl || !cacheControl.isPrivate);
|
|
1198
|
+
const parsedResponse = opt.parse
|
|
1199
|
+
? // statusText omitted — deprecated in Angular (HttpResponse defaults it)
|
|
1200
|
+
new HttpResponse({
|
|
1201
|
+
body: opt.parse(event.body),
|
|
1202
|
+
headers: event.headers,
|
|
1203
|
+
status: event.status,
|
|
1204
|
+
url: event.url ?? undefined,
|
|
1205
|
+
})
|
|
1206
|
+
: event;
|
|
1207
|
+
cache.store(key, parsedResponse, staleTime, ttl, persist);
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
// 304 → server confirmed our cached entry is still valid. Re-stamp the
|
|
1211
|
+
// existing entry so subsequent reads within the new freshness window
|
|
1212
|
+
// don't trigger another revalidation round-trip.
|
|
1213
|
+
if (event.status === 304 && entry) {
|
|
1214
|
+
// ...unless the key was invalidated while this conditional request was in
|
|
1215
|
+
// flight (e.g. by a mutation) — re-storing would resurrect deleted data
|
|
1216
|
+
if (!cache.getUntracked(key))
|
|
1217
|
+
return;
|
|
1218
|
+
const cacheControl = parseCacheControlHeader(event);
|
|
1219
|
+
const { staleTime, ttl } = opt.ignoreCacheControl
|
|
1220
|
+
? opt
|
|
1221
|
+
: resolveTimings(cacheControl, opt.staleTime, opt.ttl);
|
|
1222
|
+
const persist = (opt.persist ?? false) &&
|
|
1223
|
+
persistable &&
|
|
1224
|
+
(opt.ignoreCacheControl || !cacheControl.isPrivate);
|
|
1225
|
+
cache.store(key, entry.value, staleTime, ttl, persist);
|
|
1226
|
+
}
|
|
1227
|
+
}), map((event) => {
|
|
1228
|
+
// handle 304 responses due to eTag/last-modified
|
|
1229
|
+
if (event instanceof HttpResponse && event.status === 304 && entry) {
|
|
1230
|
+
return entry.value;
|
|
1231
|
+
}
|
|
1232
|
+
return event;
|
|
1233
|
+
}));
|
|
1234
|
+
});
|
|
955
1235
|
};
|
|
956
1236
|
}
|
|
957
1237
|
|
|
@@ -1173,6 +1453,12 @@ function noDedupe(ctx = new HttpContext()) {
|
|
|
1173
1453
|
* only the first request will be sent to the server. Subsequent requests will
|
|
1174
1454
|
* receive the response from the first request.
|
|
1175
1455
|
*
|
|
1456
|
+
* Relationship to `createCacheInterceptor`: the cache interceptor has built-in
|
|
1457
|
+
* single-flight for CACHE-ENABLED requests (keyed by the cache key). This interceptor
|
|
1458
|
+
* covers everything the cache doesn't see — non-cached resources, plain HttpClient
|
|
1459
|
+
* calls, DELETEs — keyed by the request hash. Installing both is the recommended
|
|
1460
|
+
* setup; where they overlap, this one degrades to a no-op passthrough.
|
|
1461
|
+
*
|
|
1176
1462
|
* @param allowed - An array of HTTP methods for which deduplication should be enabled.
|
|
1177
1463
|
* Defaults to `['GET', 'DELETE', 'HEAD', 'OPTIONS']`.
|
|
1178
1464
|
* @param keyFn - Optional function to compute the dedupe key from a request.
|
|
@@ -1207,13 +1493,7 @@ function createDedupeRequestsInterceptor(allowed = ['GET', 'DELETE', 'HEAD', 'OP
|
|
|
1207
1493
|
return (req, next) => {
|
|
1208
1494
|
if (!DEDUPE_METHODS.has(req.method) || req.context.get(NO_DEDUPE))
|
|
1209
1495
|
return next(req);
|
|
1210
|
-
|
|
1211
|
-
const found = inFlight.get(key);
|
|
1212
|
-
if (found)
|
|
1213
|
-
return found;
|
|
1214
|
-
const request = next(req).pipe(finalize(() => inFlight.delete(key)), shareReplay({ bufferSize: 1, refCount: true }));
|
|
1215
|
-
inFlight.set(key, request);
|
|
1216
|
-
return request;
|
|
1496
|
+
return sharePending(inFlight, keyFn(req), () => next(req));
|
|
1217
1497
|
};
|
|
1218
1498
|
}
|
|
1219
1499
|
|
|
@@ -1397,24 +1677,61 @@ function persistResourceValues(resource, shouldPersist = false, equal) {
|
|
|
1397
1677
|
};
|
|
1398
1678
|
}
|
|
1399
1679
|
|
|
1400
|
-
// refresh resource every n
|
|
1401
|
-
function refresh(resource, destroyRef,
|
|
1402
|
-
|
|
1680
|
+
// refresh resource every n milliseconds and/or on visibility/reconnect transitions.
|
|
1681
|
+
function refresh(resource, destroyRef, opt, inactive, triggers) {
|
|
1682
|
+
const normalized = typeof opt === 'number' ? { interval: opt } : (opt ?? {});
|
|
1683
|
+
const { interval: ms, onFocus = false, onReconnect = false, } = normalized;
|
|
1684
|
+
const hasInterval = !!ms; // 0 excluded — not a valid polling cadence
|
|
1685
|
+
const hasTriggerEffects = !!triggers && (onFocus || onReconnect);
|
|
1686
|
+
if (!hasInterval && !hasTriggerEffects)
|
|
1403
1687
|
return resource; // no refresh requested
|
|
1404
1688
|
const tick = () => {
|
|
1405
1689
|
if (inactive?.())
|
|
1406
|
-
return; // disabled / paused → skip
|
|
1690
|
+
return; // disabled / paused → skip
|
|
1407
1691
|
resource.reload();
|
|
1408
1692
|
};
|
|
1693
|
+
const effectRefs = [];
|
|
1694
|
+
if (triggers && onFocus) {
|
|
1695
|
+
const vis = triggers.visibility;
|
|
1696
|
+
let prev = untracked(vis);
|
|
1697
|
+
effectRefs.push(effect(() => {
|
|
1698
|
+
const next = vis();
|
|
1699
|
+
const was = prev;
|
|
1700
|
+
prev = next;
|
|
1701
|
+
// only the hidden → visible TRANSITION refreshes — not the initial run
|
|
1702
|
+
if (was !== 'visible' && next === 'visible')
|
|
1703
|
+
untracked(tick);
|
|
1704
|
+
}, { injector: triggers.injector }));
|
|
1705
|
+
}
|
|
1706
|
+
if (triggers && onReconnect) {
|
|
1707
|
+
const online = triggers.online;
|
|
1708
|
+
let prev = untracked(online);
|
|
1709
|
+
effectRefs.push(effect(() => {
|
|
1710
|
+
const next = online();
|
|
1711
|
+
const was = prev;
|
|
1712
|
+
prev = next;
|
|
1713
|
+
if (!was && next)
|
|
1714
|
+
untracked(tick);
|
|
1715
|
+
}, { injector: triggers.injector }));
|
|
1716
|
+
}
|
|
1717
|
+
if (!hasInterval) {
|
|
1718
|
+
return {
|
|
1719
|
+
...resource,
|
|
1720
|
+
destroy: () => {
|
|
1721
|
+
effectRefs.forEach((ref) => ref.destroy());
|
|
1722
|
+
resource.destroy();
|
|
1723
|
+
},
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1409
1726
|
// 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.
|
|
1410
|
-
let sub = interval(
|
|
1727
|
+
let sub = interval(ms)
|
|
1411
1728
|
.pipe(takeUntilDestroyed(destroyRef))
|
|
1412
1729
|
.subscribe(tick);
|
|
1413
1730
|
const reload = () => {
|
|
1414
1731
|
sub.unsubscribe(); // do not conflict with manual reload
|
|
1415
1732
|
const hasReloaded = resource.reload();
|
|
1416
1733
|
// resubscribe after manual reload
|
|
1417
|
-
sub = interval(
|
|
1734
|
+
sub = interval(ms)
|
|
1418
1735
|
.pipe(takeUntilDestroyed(destroyRef))
|
|
1419
1736
|
.subscribe(tick);
|
|
1420
1737
|
return hasReloaded;
|
|
@@ -1424,6 +1741,7 @@ function refresh(resource, destroyRef, refresh, inactive) {
|
|
|
1424
1741
|
reload,
|
|
1425
1742
|
destroy: () => {
|
|
1426
1743
|
sub.unsubscribe();
|
|
1744
|
+
effectRefs.forEach((ref) => ref.destroy());
|
|
1427
1745
|
resource.destroy();
|
|
1428
1746
|
},
|
|
1429
1747
|
};
|
|
@@ -1470,6 +1788,7 @@ function retryOnError(res, opt, onError) {
|
|
|
1470
1788
|
|
|
1471
1789
|
class ResourceSensors {
|
|
1472
1790
|
networkStatus = sensor('networkStatus');
|
|
1791
|
+
pageVisibility = sensor('pageVisibility');
|
|
1473
1792
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: ResourceSensors, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1474
1793
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: ResourceSensors, providedIn: 'root' });
|
|
1475
1794
|
}
|
|
@@ -1482,6 +1801,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImpo
|
|
|
1482
1801
|
function injectNetworkStatus() {
|
|
1483
1802
|
return inject(ResourceSensors).networkStatus;
|
|
1484
1803
|
}
|
|
1804
|
+
function injectPageVisibility() {
|
|
1805
|
+
return inject(ResourceSensors).pageVisibility;
|
|
1806
|
+
}
|
|
1485
1807
|
|
|
1486
1808
|
function toResourceObject(res) {
|
|
1487
1809
|
return {
|
|
@@ -1541,9 +1863,19 @@ function queryResource(request, options0) {
|
|
|
1541
1863
|
const eq = options?.triggerOnSameRequest
|
|
1542
1864
|
? undefined
|
|
1543
1865
|
: (options?.equalRequest ?? createEqualRequest());
|
|
1866
|
+
// Opt-in auto-pausing: `true` reads the ambient Activity boundary (no-op outside
|
|
1867
|
+
// one), a predicate is used directly. Composes with the manual `ctx.paused` path.
|
|
1868
|
+
const pauseOpt = options?.pause ?? false;
|
|
1869
|
+
const externallyPaused = pauseOpt === false
|
|
1870
|
+
? () => false
|
|
1871
|
+
: typeof pauseOpt === 'function'
|
|
1872
|
+
? pauseOpt
|
|
1873
|
+
: options?.injector
|
|
1874
|
+
? runInInjectionContext(options.injector, injectPaused)
|
|
1875
|
+
: injectPaused();
|
|
1544
1876
|
const requestCtx = { paused: PAUSED };
|
|
1545
1877
|
const rawResult = computed(() => request(requestCtx));
|
|
1546
|
-
const paused = computed(() => rawResult() === PAUSED);
|
|
1878
|
+
const paused = computed(() => rawResult() === PAUSED || externallyPaused());
|
|
1547
1879
|
const rawRequest = computed(() => {
|
|
1548
1880
|
const r = rawResult();
|
|
1549
1881
|
return r === PAUSED ? undefined : (r ?? undefined);
|
|
@@ -1553,9 +1885,12 @@ function queryResource(request, options0) {
|
|
|
1553
1885
|
return 'offline';
|
|
1554
1886
|
if (cb.isOpen())
|
|
1555
1887
|
return 'circuit-open';
|
|
1556
|
-
//
|
|
1557
|
-
// while
|
|
1558
|
-
|
|
1888
|
+
// Both pause sources report 'no-request' here — ctx.paused makes rawRequest
|
|
1889
|
+
// undefined, while the external `pause` option still yields a real request, so it
|
|
1890
|
+
// must be checked explicitly. Either way this also stops polling/refresh triggers
|
|
1891
|
+
// (their inactive() guard reads disabledReason), while stableRequest below HOLDS
|
|
1892
|
+
// the last request so the value is kept (no refetch on resume).
|
|
1893
|
+
if (paused() || !rawRequest())
|
|
1559
1894
|
return 'no-request';
|
|
1560
1895
|
return null;
|
|
1561
1896
|
});
|
|
@@ -1589,8 +1924,10 @@ function queryResource(request, options0) {
|
|
|
1589
1924
|
return a === b;
|
|
1590
1925
|
},
|
|
1591
1926
|
});
|
|
1927
|
+
const varyHeaders = typeof options?.cache === 'object' ? options.cache.varyHeaders : undefined;
|
|
1592
1928
|
const hashFn = typeof options?.cache === 'object'
|
|
1593
|
-
? (options.cache.hash ??
|
|
1929
|
+
? (options.cache.hash ??
|
|
1930
|
+
((r) => hashRequest(r, varyHeaders)))
|
|
1594
1931
|
: hashRequest;
|
|
1595
1932
|
const staleTime = typeof options?.cache === 'object' ? options.cache.staleTime : 0;
|
|
1596
1933
|
const ttl = typeof options?.cache === 'object' ? options.cache.ttl : undefined;
|
|
@@ -1651,22 +1988,30 @@ function queryResource(request, options0) {
|
|
|
1651
1988
|
};
|
|
1652
1989
|
},
|
|
1653
1990
|
});
|
|
1654
|
-
// A disabled (offline / circuit-open / no-request) or PAUSED resource must not poll
|
|
1655
|
-
|
|
1991
|
+
// A disabled (offline / circuit-open / no-request) or PAUSED resource must not poll
|
|
1992
|
+
// or react to focus/reconnect.
|
|
1993
|
+
resource = refresh(resource, destroyRef, options?.refresh, () => disabledReason() !== null, {
|
|
1994
|
+
injector: options?.injector ?? inject(Injector),
|
|
1995
|
+
visibility: injectPageVisibility(),
|
|
1996
|
+
online: networkAvailable,
|
|
1997
|
+
});
|
|
1656
1998
|
resource = retryOnError(resource, options?.retry, options?.onError);
|
|
1657
1999
|
resource = persistResourceValues(resource, options?.keepPrevious, options?.equal);
|
|
1658
2000
|
const set = (value) => {
|
|
1659
2001
|
resource.value.set(value);
|
|
1660
2002
|
const k = untracked(cacheKey);
|
|
1661
2003
|
if (options?.cache && k)
|
|
1662
|
-
cache.store(k,
|
|
2004
|
+
cache.store(k,
|
|
2005
|
+
// statusText omitted — deprecated in Angular (HttpResponse defaults it)
|
|
2006
|
+
new HttpResponse({
|
|
1663
2007
|
body: value,
|
|
1664
2008
|
status: 200,
|
|
1665
|
-
statusText: 'OK',
|
|
1666
2009
|
}), staleTime, ttl, persist);
|
|
1667
2010
|
};
|
|
1668
2011
|
const update = (updater) => {
|
|
1669
|
-
|
|
2012
|
+
// baseline on the COMPOSED value (cache-preferring): the cache entry can be newer
|
|
2013
|
+
// than resource.value (cross-tab sync, another instance's set)
|
|
2014
|
+
set(updater(untracked(value)));
|
|
1670
2015
|
};
|
|
1671
2016
|
const value = options?.cache
|
|
1672
2017
|
? toWritable(computed(() => cacheEntry()?.value ?? resource.value()), set, update)
|
|
@@ -1749,6 +2094,101 @@ function queryResource(request, options0) {
|
|
|
1749
2094
|
return ref;
|
|
1750
2095
|
}
|
|
1751
2096
|
|
|
2097
|
+
/**
|
|
2098
|
+
* Creates a paginated HTTP resource over {@link queryResource}: one page request at a
|
|
2099
|
+
* time, accumulated into a `pages` signal — cursor- and offset-based pagination both
|
|
2100
|
+
* fit through `getNextPageParam`. Each page request inherits the full queryResource
|
|
2101
|
+
* feature set (caching per page, retries, circuit breaker, refresh triggers).
|
|
2102
|
+
*
|
|
2103
|
+
* @example
|
|
2104
|
+
* ```ts
|
|
2105
|
+
* const posts = infiniteQueryResource<PostPage, PostPage, number>(
|
|
2106
|
+
* ({ pageParam }) => ({ url: '/api/posts', params: { page: pageParam } }),
|
|
2107
|
+
* {
|
|
2108
|
+
* initialPageParam: 0,
|
|
2109
|
+
* getNextPageParam: (last, all) => (last.items.length < 20 ? null : all.length),
|
|
2110
|
+
* cache: true,
|
|
2111
|
+
* },
|
|
2112
|
+
* );
|
|
2113
|
+
*
|
|
2114
|
+
* // template:
|
|
2115
|
+
* // @for (page of posts.pages(); track $index) { ... }
|
|
2116
|
+
* // <button (click)="posts.fetchNextPage()" [disabled]="!posts.hasNextPage()">More</button>
|
|
2117
|
+
* const flat = computed(() => posts.pages().flatMap((p) => p.items));
|
|
2118
|
+
* ```
|
|
2119
|
+
*/
|
|
2120
|
+
function infiniteQueryResource(request, options) {
|
|
2121
|
+
const { initialPageParam, getNextPageParam, ...rest } = options;
|
|
2122
|
+
const injector = options.injector ?? inject(Injector);
|
|
2123
|
+
const pageParam = signal(initialPageParam);
|
|
2124
|
+
// pages keyed by the param that produced them, so a reload of an already-loaded
|
|
2125
|
+
// page REPLACES its slot instead of appending a duplicate
|
|
2126
|
+
const loaded = signal([]);
|
|
2127
|
+
const resource = queryResource(
|
|
2128
|
+
// forward queryResource's own context so the fn can return ctx.paused —
|
|
2129
|
+
// pausing holds the loaded pages and stops page fetches until unpaused
|
|
2130
|
+
(qctx) => request({ ...qctx, pageParam: pageParam() }), { ...rest, injector });
|
|
2131
|
+
const appendRef = effect(() => {
|
|
2132
|
+
if (resource.status() !== ResourceStatus.Resolved)
|
|
2133
|
+
return;
|
|
2134
|
+
const page = resource.value();
|
|
2135
|
+
if (page === undefined)
|
|
2136
|
+
return;
|
|
2137
|
+
untracked(() => {
|
|
2138
|
+
const param = pageParam();
|
|
2139
|
+
loaded.update((list) => {
|
|
2140
|
+
const idx = list.findIndex((e) => Object.is(e.param, param));
|
|
2141
|
+
if (idx >= 0) {
|
|
2142
|
+
const copy = [...list];
|
|
2143
|
+
copy[idx] = { param, page };
|
|
2144
|
+
return copy;
|
|
2145
|
+
}
|
|
2146
|
+
return [...list, { param, page }];
|
|
2147
|
+
});
|
|
2148
|
+
});
|
|
2149
|
+
}, { injector });
|
|
2150
|
+
const pages = computed(() => loaded().map((e) => e.page));
|
|
2151
|
+
const nextPageParam = computed(() => {
|
|
2152
|
+
const all = pages();
|
|
2153
|
+
if (all.length === 0)
|
|
2154
|
+
return null;
|
|
2155
|
+
return getNextPageParam(all[all.length - 1], all) ?? null;
|
|
2156
|
+
});
|
|
2157
|
+
const hasNextPage = computed(() => nextPageParam() !== null);
|
|
2158
|
+
const fetchNextPage = () => {
|
|
2159
|
+
if (untracked(resource.isLoading))
|
|
2160
|
+
return; // one page at a time
|
|
2161
|
+
const next = untracked(nextPageParam);
|
|
2162
|
+
if (next === null)
|
|
2163
|
+
return;
|
|
2164
|
+
pageParam.set(next);
|
|
2165
|
+
};
|
|
2166
|
+
const reset = () => {
|
|
2167
|
+
loaded.set([]);
|
|
2168
|
+
if (Object.is(untracked(pageParam), initialPageParam)) {
|
|
2169
|
+
resource.reload(); // param unchanged — force the refetch
|
|
2170
|
+
}
|
|
2171
|
+
else {
|
|
2172
|
+
pageParam.set(initialPageParam);
|
|
2173
|
+
}
|
|
2174
|
+
};
|
|
2175
|
+
return {
|
|
2176
|
+
pages,
|
|
2177
|
+
hasNextPage,
|
|
2178
|
+
isFetchingNextPage: computed(() => resource.isLoading() && loaded().length > 0),
|
|
2179
|
+
isLoading: resource.isLoading,
|
|
2180
|
+
status: resource.status,
|
|
2181
|
+
error: resource.error,
|
|
2182
|
+
fetchNextPage,
|
|
2183
|
+
reload: () => resource.reload(),
|
|
2184
|
+
reset,
|
|
2185
|
+
destroy: () => {
|
|
2186
|
+
appendRef.destroy();
|
|
2187
|
+
resource.destroy();
|
|
2188
|
+
},
|
|
2189
|
+
};
|
|
2190
|
+
}
|
|
2191
|
+
|
|
1752
2192
|
function manualQueryResource(request, options) {
|
|
1753
2193
|
const trigger = signal({ epoch: 0 }, {
|
|
1754
2194
|
equal: (a, b) => a.epoch === b.epoch,
|
|
@@ -1765,6 +2205,12 @@ function manualQueryResource(request, options) {
|
|
|
1765
2205
|
equal: () => false,
|
|
1766
2206
|
});
|
|
1767
2207
|
const resource = queryResource(req, options);
|
|
2208
|
+
// Shared across trigger() calls: a per-call watcher could observe the PREVIOUS
|
|
2209
|
+
// request's `resolved` status before this trigger's load flips the resource to
|
|
2210
|
+
// loading (effect ordering within a flush is unspecified) and resolve with stale
|
|
2211
|
+
// data; concurrent triggers would also cross-resolve each other's promises.
|
|
2212
|
+
let pending = [];
|
|
2213
|
+
let watcher = null;
|
|
1768
2214
|
return {
|
|
1769
2215
|
...resource,
|
|
1770
2216
|
trigger: (override, injectorOverride) => {
|
|
@@ -1773,15 +2219,43 @@ function manualQueryResource(request, options) {
|
|
|
1773
2219
|
override,
|
|
1774
2220
|
}));
|
|
1775
2221
|
return new Promise((res, rej) => {
|
|
1776
|
-
|
|
2222
|
+
if (untracked(req) === undefined) {
|
|
2223
|
+
// the request fn produced nothing — no load will ever start, so a watcher
|
|
2224
|
+
// would hang this promise forever
|
|
2225
|
+
rej(new Error('[@mmstack/resource]: trigger() produced no request (the request fn returned undefined)'));
|
|
2226
|
+
return;
|
|
2227
|
+
}
|
|
2228
|
+
pending.push({ res, rej });
|
|
2229
|
+
// an active watcher (concurrent trigger) settles ALL pending promises with
|
|
2230
|
+
// the final result of the latest request — TanStack-style latest-wins
|
|
2231
|
+
if (watcher)
|
|
2232
|
+
return;
|
|
2233
|
+
// only accept a settle AFTER the load for this trigger has been observed —
|
|
2234
|
+
// the pre-trigger status may still be a stale `resolved`/`error`
|
|
2235
|
+
let sawLoading = false;
|
|
2236
|
+
watcher = nestedEffect(() => {
|
|
1777
2237
|
const status = resource.status();
|
|
1778
|
-
if (status === ResourceStatus.
|
|
1779
|
-
|
|
1780
|
-
|
|
2238
|
+
if (status === ResourceStatus.Loading ||
|
|
2239
|
+
status === ResourceStatus.Reloading) {
|
|
2240
|
+
sawLoading = true;
|
|
2241
|
+
return;
|
|
1781
2242
|
}
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
2243
|
+
if (!sawLoading)
|
|
2244
|
+
return;
|
|
2245
|
+
if (status === ResourceStatus.Resolved ||
|
|
2246
|
+
status === ResourceStatus.Error) {
|
|
2247
|
+
const settled = pending;
|
|
2248
|
+
pending = [];
|
|
2249
|
+
watcher?.destroy();
|
|
2250
|
+
watcher = null;
|
|
2251
|
+
if (status === ResourceStatus.Resolved) {
|
|
2252
|
+
const value = untracked(resource.value);
|
|
2253
|
+
settled.forEach((p) => p.res(value));
|
|
2254
|
+
}
|
|
2255
|
+
else {
|
|
2256
|
+
const err = untracked(resource.error);
|
|
2257
|
+
settled.forEach((p) => p.rej(err));
|
|
2258
|
+
}
|
|
1785
2259
|
}
|
|
1786
2260
|
}, { injector: injectorOverride ?? injector });
|
|
1787
2261
|
});
|
|
@@ -1861,7 +2335,8 @@ function mutationResource(request, options0 = {}) {
|
|
|
1861
2335
|
};
|
|
1862
2336
|
// `register` is pulled out (and forced off on the inner query below) so the mutation ref is
|
|
1863
2337
|
// the only thing registered into the transition scope, not its internal query resource.
|
|
1864
|
-
const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, ...rest } = options;
|
|
2338
|
+
const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, invalidates, ...rest } = options;
|
|
2339
|
+
const cache = invalidates ? injectQueryCache(options.injector) : undefined;
|
|
1865
2340
|
const requestEqual = equalRequest ?? createEqualRequest(equal);
|
|
1866
2341
|
// A mutation is an imperative command, so `triggerOnSameRequest` means "fire on EVERY mutate(),
|
|
1867
2342
|
// even with an identical body". By default we dedup an identical value/request while one is in
|
|
@@ -1972,8 +2447,19 @@ function mutationResource(request, options0 = {}) {
|
|
|
1972
2447
|
.subscribe((result) => {
|
|
1973
2448
|
if (result.status === 'error')
|
|
1974
2449
|
onError?.(result.error, ctx);
|
|
1975
|
-
else
|
|
2450
|
+
else {
|
|
1976
2451
|
onSuccess?.(result.value, ctx);
|
|
2452
|
+
if (cache && invalidates) {
|
|
2453
|
+
const mutation = untracked(lastValue);
|
|
2454
|
+
const prefixes = typeof invalidates === 'function'
|
|
2455
|
+
? invalidates(result.value, (mutation === NULL_VALUE ? undefined : mutation))
|
|
2456
|
+
: invalidates;
|
|
2457
|
+
// auto-keys are `${method}:${url}:...` — a `GET:`-prefixed url prefix hits
|
|
2458
|
+
// the url with any params/subpaths and every varyHeaders variant
|
|
2459
|
+
for (const prefix of prefixes)
|
|
2460
|
+
cache.invalidatePrefix(`GET:${prefix}`);
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
1977
2463
|
onSettled?.(ctx);
|
|
1978
2464
|
ctx = undefined;
|
|
1979
2465
|
next.set(NULL_VALUE);
|
|
@@ -1982,15 +2468,31 @@ function mutationResource(request, options0 = {}) {
|
|
|
1982
2468
|
const ref = {
|
|
1983
2469
|
...resource,
|
|
1984
2470
|
destroy: () => {
|
|
2471
|
+
// queue first — a late queue flush must not poke an already-destroyed resource
|
|
2472
|
+
queueRef.destroy();
|
|
1985
2473
|
statusSub.unsubscribe();
|
|
1986
2474
|
resource.destroy();
|
|
1987
|
-
queueRef.destroy();
|
|
1988
2475
|
},
|
|
1989
2476
|
mutate: (value, ictx) => {
|
|
1990
2477
|
if (shouldQueue) {
|
|
1991
2478
|
return queue.update((q) => [...q, [value, ictx]]);
|
|
1992
2479
|
}
|
|
1993
2480
|
else {
|
|
2481
|
+
// latest-wins: a mutation already in flight gets superseded (its request is
|
|
2482
|
+
// aborted by the request change), so its onSuccess/onError will never fire —
|
|
2483
|
+
// settle its context NOW so optimistic state can be rolled back/cleaned up
|
|
2484
|
+
if (untracked(next) !== NULL_VALUE) {
|
|
2485
|
+
if (isDevMode())
|
|
2486
|
+
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.');
|
|
2487
|
+
try {
|
|
2488
|
+
onSettled?.(ctx);
|
|
2489
|
+
}
|
|
2490
|
+
catch (settleErr) {
|
|
2491
|
+
if (isDevMode())
|
|
2492
|
+
console.error('[@mmstack/resource]: error thrown in onSettled hook for a superseded mutation', settleErr);
|
|
2493
|
+
}
|
|
2494
|
+
ctx = undefined;
|
|
2495
|
+
}
|
|
1994
2496
|
try {
|
|
1995
2497
|
ctx = onMutate?.(value, ictx);
|
|
1996
2498
|
next.set(value);
|
|
@@ -2018,5 +2520,5 @@ function mutationResource(request, options0 = {}) {
|
|
|
2018
2520
|
* Generated bundle index. Do not edit.
|
|
2019
2521
|
*/
|
|
2020
2522
|
|
|
2021
|
-
export { Cache, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
|
|
2523
|
+
export { Cache, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
|
|
2022
2524
|
//# sourceMappingURL=mmstack-resource.mjs.map
|