@mmstack/resource 22.1.5 → 22.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,970 +1,1054 @@
1
- import { HttpHeaders, HttpResponse, HttpParams, HttpContextToken, HttpContext, httpResource, HttpClient } from '@angular/common/http';
1
+ import { HttpHeaders, HttpParams, HttpResponse, HttpContextToken, HttpContext, httpResource, HttpClient } from '@angular/common/http';
2
2
  import * as i0 from '@angular/core';
3
3
  import { isDevMode, signal, computed, untracked, InjectionToken, inject, PLATFORM_ID, DestroyRef, effect, Injector, Injectable, runInInjectionContext, linkedSignal } from '@angular/core';
4
4
  import { mutable, toWritable, keepPrevious, sensor, injectTransitionScope, injectPaused, nestedEffect } from '@mmstack/primitives';
5
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
- function createNoopDB() {
9
- return {
10
- getAll: async () => [],
11
- store: async () => {
12
- // noop
13
- },
14
- remove: async () => {
15
- // noop
16
- },
17
- };
8
+ /**
9
+ * Returns `true` for any object-like value whose own enumerable keys should
10
+ * be sorted for stable hashing. Excludes arrays (positional), `Date`
11
+ * (handled by `toJSON`), `Map`/`Set` (handled explicitly), and binary types
12
+ * (`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`/typed arrays — these
13
+ * should be branched on before reaching `hash()`, typically by `hashRequest`).
14
+ *
15
+ * Plain objects, class instances, and `Object.create(null)` all qualify.
16
+ */
17
+ function isHashableObject(value) {
18
+ if (value === null || typeof value !== 'object')
19
+ return false;
20
+ if (Array.isArray(value))
21
+ return false;
22
+ if (value instanceof Date)
23
+ return false;
24
+ if (value instanceof Map)
25
+ return false;
26
+ if (value instanceof Set)
27
+ return false;
28
+ if (typeof Blob !== 'undefined' && value instanceof Blob)
29
+ return false;
30
+ if (typeof FormData !== 'undefined' && value instanceof FormData)
31
+ return false;
32
+ if (typeof URLSearchParams !== 'undefined' &&
33
+ value instanceof URLSearchParams)
34
+ return false;
35
+ if (value instanceof ArrayBuffer)
36
+ return false;
37
+ if (ArrayBuffer.isView(value))
38
+ return false;
39
+ return true;
18
40
  }
19
- function toCacheDB(db, storeName) {
20
- const getAll = async () => {
21
- const now = Date.now();
22
- return new Promise((res, rej) => {
23
- const transaction = db.transaction(storeName, 'readonly');
24
- const store = transaction.objectStore(storeName);
25
- const request = store.getAll();
26
- request.onsuccess = () => res(request.result);
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);
30
- })
31
- .then((entries) => entries.filter((e) => e.expiresAt > now))
32
- .catch((err) => {
33
- if (isDevMode())
34
- console.error('Error getting all items from cache DB:', err);
35
- return [];
36
- });
37
- };
38
- const store = (value) => {
39
- return new Promise((res, rej) => {
40
- const transaction = db.transaction(storeName, 'readwrite');
41
- const store = transaction.objectStore(storeName);
42
- store.put(value);
43
- transaction.oncomplete = () => res();
44
- transaction.onerror = () => rej(transaction.error);
45
- // QuotaExceededError surfaces as an abort in some browsers
46
- transaction.onabort = () => rej(transaction.error);
47
- }).catch((err) => {
48
- if (isDevMode())
49
- console.error('Error storing item in cache DB:', err);
50
- });
51
- };
52
- const remove = (key) => {
53
- return new Promise((res, rej) => {
54
- const transaction = db.transaction(storeName, 'readwrite');
55
- const store = transaction.objectStore(storeName);
56
- store.delete(key);
57
- transaction.oncomplete = () => res();
58
- transaction.onerror = () => rej(transaction.error);
59
- transaction.onabort = () => rej(transaction.error);
60
- }).catch((err) => {
61
- if (isDevMode())
62
- console.error('Error removing item from cache DB:', err);
63
- });
64
- };
65
- return {
66
- getAll,
67
- store,
68
- remove,
69
- };
41
+ function sortKeys(val) {
42
+ return Object.keys(val)
43
+ .toSorted()
44
+ .reduce((result, key) => {
45
+ result[key] = val[key];
46
+ return result;
47
+ }, {});
70
48
  }
71
- function createSingleStoreDB(name, getStoreName, version = 1) {
72
- const storeName = getStoreName(version);
73
- if (!globalThis.indexedDB)
74
- return Promise.resolve(createNoopDB());
75
- return new Promise((res, rej) => {
76
- if (version < 1) {
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
49
+ /**
50
+ * Internal helper to generate a stable JSON string from an array.
51
+ * - Object-like values (plain, class instances, null-proto) get their own
52
+ * enumerable keys sorted alphabetically.
53
+ * - `Map` → marker object with sorted entries (sorted by `JSON.stringify(key)`).
54
+ * - `Set` → marker object with sorted values (sorted by `JSON.stringify(value)`).
55
+ * - Arrays preserve order. `Date` serializes via `toJSON`.
56
+ *
57
+ * @internal
58
+ */
59
+ function hashKey(queryKey) {
60
+ return JSON.stringify(queryKey, (_, val) => {
61
+ if (val instanceof Map) {
62
+ // Schwartzian: compute each entry's sort key (recursive hash of the
63
+ // Map key) once, then sort by the cheap string compare.
64
+ const entries = [...val.entries()]
65
+ .map((e) => [hash(e[0]), e])
66
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
67
+ .map(([, e]) => e);
68
+ return { __map__: entries };
79
69
  }
80
- const req = indexedDB.open(name, version);
81
- req.onupgradeneeded = (event) => {
82
- const db = req.result;
83
- const oldVersion = event.oldVersion;
84
- db.createObjectStore(storeName, { keyPath: 'key' });
85
- if (oldVersion > 0) {
86
- db.deleteObjectStore(getStoreName(oldVersion));
87
- }
88
- };
89
- req.onerror = () => {
90
- rej(req.error);
91
- };
92
- req.onsuccess = () => res(req.result);
93
- })
94
- .then((db) => toCacheDB(db, storeName))
95
- .catch((err) => {
96
- if (isDevMode())
97
- console.error('Error creating query DB:', err);
98
- return createNoopDB();
70
+ if (val instanceof Set) {
71
+ const values = [...val]
72
+ .map((v) => [hash(v), v])
73
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
74
+ .map(([, v]) => v);
75
+ return { __set__: values };
76
+ }
77
+ if (isHashableObject(val))
78
+ return sortKeys(val);
79
+ return val;
99
80
  });
100
81
  }
101
-
102
- function generateID() {
103
- if (globalThis.crypto?.randomUUID) {
104
- return globalThis.crypto.randomUUID();
105
- }
106
- return Math.random().toString(36).substring(2);
107
- }
108
- function isSyncMessage(msg) {
109
- return (typeof msg === 'object' &&
110
- msg !== null &&
111
- 'type' in msg &&
112
- msg.type === 'cache-sync-message');
82
+ /**
83
+ * Generates a stable, unique string hash from one or more arguments.
84
+ * Useful for creating cache keys or identifiers where object key order shouldn't matter.
85
+ *
86
+ * How it works:
87
+ * - Object-like values (plain objects, class instances, `Object.create(null)`) have
88
+ * their own enumerable keys sorted alphabetically before hashing. This ensures
89
+ * `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same hash.
90
+ * - `Map` and `Set` are serialized via stable, sorted markers (`__map__` / `__set__`).
91
+ * - Arrays preserve positional order; `Date` uses its ISO string via `toJSON`.
92
+ *
93
+ * @param {...unknown} args Values to include in the hash.
94
+ * @returns A stable string hash representing the input arguments.
95
+ * @example
96
+ * hash('posts', 10);
97
+ * // => '["posts",10]'
98
+ *
99
+ * hash({ a: 1, b: 2 }) === hash({ b: 2, a: 1 }); // true
100
+ *
101
+ * hash(new Map([['a', 1]])) === hash(new Map([['a', 1]])); // true
102
+ *
103
+ * // Be mindful of values JSON.stringify cannot handle (functions, undefined, Symbols)
104
+ * // hash('a', undefined, function() {}) => '["a",null,null]'
105
+ */
106
+ function hash(...args) {
107
+ return hashKey(args);
113
108
  }
109
+
114
110
  /**
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.
111
+ * Top-level field separator for auto-generated cache keys. ASCII Unit Separator
112
+ * (`\x1f`) is deliberately content-rare: it never occurs in HTTP method tokens,
113
+ * URLs, or `encodeURIComponent`/digest output (params, vary headers, body hash),
114
+ * so the structural layout stays unambiguous even when a custom `cache.hash`
115
+ * *prepends* a namespace with ordinary chars (e.g. `tenant:${hashRequest(req)}`).
116
+ * Survives `JSON.stringify` (IndexedDB persistence) and `structuredClone`
117
+ * (cross-tab broadcast) intact.
119
118
  */
120
- const MAX_TIMER_DELAY = 2 ** 31 - 1;
121
- const ONE_DAY = 1000 * 60 * 60 * 24;
122
- const ONE_HOUR = 1000 * 60 * 60;
123
- const DEFAULT_CLEANUP_OPT = {
124
- type: 'lru',
125
- maxSize: 200,
126
- checkInterval: ONE_HOUR,
127
- };
119
+ const KEY_DELIMITER = '\x1f';
128
120
  /**
129
- * A generic cache implementation that stores data with time-to-live (TTL) and stale-while-revalidate capabilities.
121
+ * Recovers the URL portion of an auto-generated cache key the segment between
122
+ * the 1st and 2nd {@link KEY_DELIMITER} (the key shape is
123
+ * `method␟url␟responseType[␟…]`). Returns `null` when the key has no delimiter
124
+ * (e.g. produced by a custom `hash` that doesn't follow this shape).
130
125
  *
131
- * @typeParam T - The type of data to be stored in the cache.
126
+ * A namespace prepended with non-delimiter chars collapses into segment 0
127
+ * (`tenant:GET`), so the URL remains segment 1 — method-agnostic and
128
+ * namespacing-tolerant by construction.
132
129
  */
133
- class Cache {
134
- ttl;
135
- staleTime;
136
- db;
137
- internal = mutable(new Map());
138
- cleanupOpt;
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 */ []));
148
- /**
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.
164
- */
165
- destroy;
166
- broadcast = () => {
167
- // noop
168
- };
169
- /**
170
- * Creates a new `Cache` instance.
171
- *
172
- * @param ttl - The default Time To Live (TTL) for cache entries, in milliseconds. Defaults to one day.
173
- * @param staleTime - The default duration, in milliseconds, during which a cache entry is considered
174
- * stale but can still be used while revalidation occurs in the background. Defaults to 1 hour.
175
- * @param cleanupOpt - Options for configuring the cache cleanup strategy. Defaults to LRU with a
176
- * `maxSize` of 200 and a `checkInterval` of one hour.
177
- * @param syncTabs - If provided, the cache will use the options a BroadcastChannel to send updates between tabs.
178
- * Defaults to `undefined`, meaning no synchronization across tabs.
179
- */
180
- constructor(ttl = ONE_DAY, staleTime = ONE_HOUR, cleanupOpt = DEFAULT_CLEANUP_OPT, syncTabs, db = Promise.resolve(createNoopDB())) {
181
- this.ttl = ttl;
182
- this.staleTime = staleTime;
183
- this.db = db;
184
- this.cleanupOpt = {
185
- ...DEFAULT_CLEANUP_OPT,
186
- ...cleanupOpt,
187
- };
188
- if (this.cleanupOpt.maxSize <= 0)
189
- throw new Error('maxSize must be greater than 0');
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;
196
- let destroySyncTabs = () => {
197
- // noop
198
- };
199
- if (syncTabs) {
200
- const channel = new BroadcastChannel(syncTabs.id);
201
- this.broadcast = (msg) => {
202
- if (msg.action === 'invalidate')
203
- return channel.postMessage({
204
- action: 'invalidate',
205
- entry: { key: msg.entry.key },
206
- cacheId: this.id,
207
- type: 'cache-sync-message',
208
- });
209
- return channel.postMessage({
210
- ...msg,
211
- entry: {
212
- ...msg.entry,
213
- value: syncTabs.serialize(msg.entry.value),
214
- },
215
- cacheId: this.id,
216
- type: 'cache-sync-message',
217
- });
218
- };
219
- channel.onmessage = (event) => {
220
- const msg = event.data;
221
- if (!isSyncMessage(msg))
222
- return;
223
- if (msg.cacheId === this.id)
224
- return; // ignore messages from this cache
225
- if (msg.action === 'store') {
226
- const value = syncTabs.deserialize(msg.entry.value);
227
- if (value === null)
228
- return;
229
- // Last-write-wins by `updated` timestamp.
230
- const existing = untracked(this.internal).get(msg.entry.key);
231
- if (existing && existing.updated >= msg.entry.updated)
232
- return;
233
- this.restoreInternal({ ...msg.entry, value });
234
- }
235
- else if (msg.action === 'invalidate') {
236
- this.invalidateInternal(msg.entry.key, true);
237
- }
238
- };
239
- destroySyncTabs = () => {
240
- channel.close();
241
- };
242
- }
243
- let destroyed = false;
244
- const destroy = () => {
245
- if (destroyed)
246
- return;
247
- destroyed = true;
248
- if (cleanupInterval !== undefined)
249
- clearInterval(cleanupInterval);
250
- destroySyncTabs();
251
- };
252
- this.db
253
- .then(async (db) => {
254
- if (destroyed)
255
- return [];
256
- return db.getAll();
257
- })
258
- .then((entries) => {
259
- if (destroyed)
260
- return;
261
- const current = untracked(this.internal);
262
- entries.forEach((entry) => {
263
- if (current.has(entry.key))
264
- return;
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);
269
- });
270
- this.hydrated = true;
271
- this.hydrationTombstones.clear();
272
- });
273
- this.destroy = destroy;
274
- }
275
- /** @internal */
276
- getInternal(key) {
277
- const keySignal = computed(() => key(), /* @ts-ignore */
278
- ...(ngDevMode ? [{ debugName: "keySignal" }] : /* istanbul ignore next */ []));
279
- return computed(() => {
280
- const key = keySignal();
281
- if (!key)
282
- return null;
283
- const found = this.internal().get(key);
284
- const now = Date.now();
285
- if (!found || found.expiresAt <= now)
286
- return null;
287
- return {
288
- ...found,
289
- isStale: found.stale <= now,
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),
299
- });
130
+ function extractUrlFromKey(key) {
131
+ const start = key.indexOf(KEY_DELIMITER);
132
+ if (start === -1)
133
+ return null;
134
+ const end = key.indexOf(KEY_DELIMITER, start + 1);
135
+ return end === -1
136
+ ? key.slice(start + 1)
137
+ : key.slice(start + 1, end);
138
+ }
139
+ /**
140
+ * @internal
141
+ * One-way ~64-bit digest from two independent FNV-1a passes. Used for header VALUES in
142
+ * cache keys: keys are persisted (IndexedDB) and broadcast cross-tab, so raw values
143
+ * (auth tokens!) must never appear in them. A single 32-bit digest's 2^-32 collision
144
+ * chance is too thin at a security boundary two colliding tokens would serve one
145
+ * user's cached data under another user's key; 64 bits puts collisions out of reach.
146
+ * High-entropy secrets are not recoverable from the digest.
147
+ */
148
+ function digestHeaderValue(value) {
149
+ let h1 = 0x811c9dc5; // FNV-1a offset basis
150
+ let h2 = 0xcbf29ce4; // independent second pass
151
+ for (let i = 0; i < value.length; i++) {
152
+ const c = value.charCodeAt(i);
153
+ h1 = Math.imul(h1 ^ c, 0x01000193); // FNV prime
154
+ h2 = Math.imul(h2 ^ c, 0x01000197); // distinct odd multiplier
300
155
  }
301
- /** @internal Imperative access bookkeeping for LRU eviction. */
302
- touch(entry) {
303
- entry.lastAccessed = Date.now();
304
- entry.useCount++;
156
+ return ((h1 >>> 0).toString(16).padStart(8, '0') +
157
+ (h2 >>> 0).toString(16).padStart(8, '0'));
158
+ }
159
+ function readHeader(headers, name) {
160
+ if (!headers)
161
+ return null;
162
+ if (headers instanceof HttpHeaders) {
163
+ const all = headers.getAll(name);
164
+ return all && all.length ? all.join(',') : null;
305
165
  }
306
- /**
307
- * Retrieves a cache entry directly (non-reactively), updating its access bookkeeping
308
- * for LRU eviction.
309
- * @internal
310
- * @param key - The key of the entry to retrieve.
311
- * @returns The cache entry, or `null` if not found or expired.
312
- */
313
- getUntracked(key) {
314
- const found = untracked(this.internal).get(key);
315
- const now = Date.now();
316
- if (!found || found.expiresAt <= now) {
317
- this.missCount.update((c) => c + 1);
166
+ // record form — header names are case-insensitive
167
+ const lower = name.toLowerCase();
168
+ for (const key of Object.keys(headers)) {
169
+ if (key.toLowerCase() !== lower)
170
+ continue;
171
+ const value = headers[key];
172
+ if (value == null)
318
173
  return null;
174
+ return Array.isArray(value) ? value.join(',') : String(value);
175
+ }
176
+ return null;
177
+ }
178
+ /**
179
+ * Content-negotiation headers whose values are low-entropy and non-identifying —
180
+ * embedded (URI-encoded) raw, keeping keys human-readable and skipping the digest.
181
+ * Anything NOT on this list (Authorization, api keys, tenant/x-* headers — we can't
182
+ * know what they carry) is one-way digested instead.
183
+ */
184
+ const SAFE_RAW_HEADERS = new Set([
185
+ 'accept',
186
+ 'accept-language',
187
+ 'content-language',
188
+ 'content-type',
189
+ ]);
190
+ const UNSAFE_HEADER_MESSAGES = new Map([
191
+ [
192
+ 'cookie',
193
+ "[@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.",
194
+ ],
195
+ [
196
+ 'set-cookie',
197
+ "[@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.",
198
+ ],
199
+ [
200
+ 'authorization',
201
+ "[@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.",
202
+ ],
203
+ [
204
+ 'x-request-id',
205
+ "[@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.",
206
+ ],
207
+ [
208
+ 'x-correlation-id',
209
+ "[@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.",
210
+ ],
211
+ [
212
+ 'if-none-match',
213
+ "[@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.",
214
+ ],
215
+ [
216
+ 'if-modified-since',
217
+ "[@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.",
218
+ ],
219
+ ]);
220
+ function normalizeVaryHeaders(headers, names) {
221
+ const isDev = isDevMode();
222
+ return names
223
+ .map((n) => n.toLowerCase())
224
+ .toSorted()
225
+ .map((name) => {
226
+ if (isDev) {
227
+ const warning = UNSAFE_HEADER_MESSAGES.get(name);
228
+ if (warning)
229
+ console.warn(warning);
319
230
  }
320
- this.touch(found);
321
- this.hitCount.update((c) => c + 1);
322
- return {
323
- ...found,
324
- isStale: found.stale <= now,
325
- };
231
+ const value = readHeader(headers, name);
232
+ if (value === null)
233
+ return `${name}=`;
234
+ // known-safe values raw (readable, cheap); everything else digested, NEVER raw —
235
+ // keys are persisted to IndexedDB and broadcast across tabs
236
+ return SAFE_RAW_HEADERS.has(name)
237
+ ? `${name}=${encodeURIComponent(value)}`
238
+ : `${name}=${digestHeaderValue(value)}`;
239
+ })
240
+ .join('&');
241
+ }
242
+ function normalizeParams(params) {
243
+ const p = params instanceof HttpParams
244
+ ? params
245
+ : new HttpParams({ fromObject: params });
246
+ return p
247
+ .keys()
248
+ .toSorted()
249
+ .map((key) => {
250
+ const encodedKey = encodeURIComponent(key);
251
+ return (p.getAll(key) ?? [])
252
+ .map((v) => `${encodedKey}=${encodeURIComponent(v)}`)
253
+ .join('&');
254
+ })
255
+ .join('&');
256
+ }
257
+ function hashBody(body) {
258
+ // File extends Blob — must check File first
259
+ if (typeof File !== 'undefined' && body instanceof File) {
260
+ return `File:${body.name}:${body.type}:${body.size}:${body.lastModified}`;
326
261
  }
327
- /**
328
- * Retrieves a cache entry as a signal.
329
- *
330
- * @param key - A function that returns the cache key. The key is a signal, allowing for dynamic keys. If the function returns null the value is also null.
331
- * @returns A signal that holds the cache entry, or `null` if not found or expired. The signal
332
- * updates whenever the cache entry changes (e.g., due to revalidation or expiration).
333
- */
334
- get(key) {
335
- return this.getInternal(key);
262
+ if (typeof Blob !== 'undefined' && body instanceof Blob) {
263
+ return `Blob:${body.type}:${body.size}`;
336
264
  }
337
- /**
338
- * Retrieves a cache entry or an object with the key if not found.
339
- *
340
- * @param key - A function that returns the cache key. The key is a signal, allowing for dynamic keys. If the function returns null the value is also null.
341
- * @returns A signal that holds the cache entry or an object with the key if not found. The signal
342
- * updates whenever the cache entry changes (e.g., due to revalidation or expiration).
343
- */
344
- getEntryOrKey(key) {
345
- const valueSig = this.getInternal(key);
346
- return computed(() => valueSig() ?? key());
265
+ if (typeof FormData !== 'undefined' && body instanceof FormData) {
266
+ const entries = [];
267
+ body.forEach((value, key) => {
268
+ entries.push([key, hashBody(value)]);
269
+ });
270
+ entries.sort(([ak, av], [bk, bv]) => ak.localeCompare(bk) || av.localeCompare(bv));
271
+ return `FormData:${entries.map(([k, v]) => `${k}=${v}`).join('&')}`;
347
272
  }
348
- /**
349
- * Stores a value in the cache.
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
- *
355
- * @param key - The key under which to store the value.
356
- * @param value - The value to store.
357
- * @param staleTime - (Optional) The stale time for this entry, in milliseconds. Overrides the default `staleTime`.
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`.
360
- */
361
- store(key, value, staleTime = this.staleTime, ttl = this.ttl, persist = false) {
362
- this.storeInternal(key, value, staleTime, ttl, false, persist);
273
+ if (typeof URLSearchParams !== 'undefined' &&
274
+ body instanceof URLSearchParams) {
275
+ const sp = new URLSearchParams(body);
276
+ sp.sort();
277
+ return `URLSearchParams:${sp.toString()}`;
363
278
  }
364
- storeInternal(key, value, staleTime = this.staleTime, ttl = this.ttl, fromSync = false, persist = false) {
365
- const entry = untracked(this.internal).get(key);
366
- // ttl cannot be less than staleTime
367
- if (ttl < staleTime)
368
- staleTime = ttl;
279
+ if (body instanceof ArrayBuffer) {
280
+ return `ArrayBuffer:${body.byteLength}`;
281
+ }
282
+ if (ArrayBuffer.isView(body)) {
283
+ return `${body.constructor.name}:${body.byteLength}`;
284
+ }
285
+ return hash(body);
286
+ }
287
+ /**
288
+ * Builds a stable cache/dedupe key from an HTTP request shape (accepts both
289
+ * `HttpRequest` and `HttpResourceRequest`).
290
+ *
291
+ * Key composition: `${method}␟${url}␟${responseType}[␟${params}][␟${body}][␟${vary}]`,
292
+ * where `␟` is {@link KEY_DELIMITER} (ASCII Unit Separator) — a content-rare top-level
293
+ * separator. Sub-fields inside `params`/`vary` keep their own `&`/`=` delimiters.
294
+ * - `method` defaults to `'GET'`, `responseType` to `'json'` (Angular defaults).
295
+ * - Query params are sorted alphabetically and URL-encoded for stability.
296
+ * - Body hashing handles `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`
297
+ * and typed arrays explicitly; everything else flows through key-sorted
298
+ * `JSON.stringify` via `hash()`.
299
+ * - `varyHeaders` (opt-in) mixes the named request headers into the key so responses
300
+ * that differ per header (e.g. `Authorization` → per-user, `Accept-Language`) get
301
+ * separate entries. Known-safe content-negotiation headers (`Accept`,
302
+ * `Accept-Language`, `Content-Language`, `Content-Type`) embed their value raw for
303
+ * readable keys; all other header VALUES are one-way digested, never embedded raw —
304
+ * keys are persisted to IndexedDB and broadcast across tabs.
305
+ */
306
+ function hashRequest(req, varyHeaders) {
307
+ const method = req.method ?? 'GET';
308
+ const responseType = req.responseType ?? 'json';
309
+ const base = `${method}${KEY_DELIMITER}${req.url}${KEY_DELIMITER}${responseType}`;
310
+ const params = req.params
311
+ ? `${KEY_DELIMITER}${normalizeParams(req.params)}`
312
+ : '';
313
+ const body = req.body != null ? `${KEY_DELIMITER}${hashBody(req.body)}` : '';
314
+ const vary = varyHeaders?.length
315
+ ? `${KEY_DELIMITER}vary(${normalizeVaryHeaders(req.headers, varyHeaders)})`
316
+ : '';
317
+ return base + params + body + vary;
318
+ }
319
+
320
+ function createNoopDB() {
321
+ return {
322
+ getAll: async () => [],
323
+ store: async () => {
324
+ // noop
325
+ },
326
+ remove: async () => {
327
+ // noop
328
+ },
329
+ };
330
+ }
331
+ function toCacheDB(db, storeName) {
332
+ const getAll = async () => {
369
333
  const now = Date.now();
370
- this.setEntry({
371
- value,
372
- created: entry?.created ?? now,
373
- updated: now,
374
- useCount: (entry?.useCount ?? 0) + 1,
375
- lastAccessed: now,
376
- stale: now + staleTime,
377
- expiresAt: now + ttl,
378
- key,
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;
408
- this.internal.mutate((map) => {
409
- map.set(next.key, { ...next, timeout });
410
- return map;
334
+ return new Promise((res, rej) => {
335
+ const transaction = db.transaction(storeName, 'readonly');
336
+ const store = transaction.objectStore(storeName);
337
+ const request = store.getAll();
338
+ request.onsuccess = () => res(request.result);
339
+ request.onerror = () => rej(request.error);
340
+ // some browsers abort (rather than error) e.g. on quota issues — without this the promise would stay pending forever
341
+ transaction.onabort = () => rej(transaction.error);
342
+ })
343
+ .then((entries) => entries.filter((e) => e.expiresAt > now))
344
+ .catch((err) => {
345
+ if (isDevMode())
346
+ console.error('Error getting all items from cache DB:', err);
347
+ return [];
411
348
  });
412
- if (!fromSync) {
413
- if (persist)
414
- this.db.then((db) => db.store(next));
415
- this.broadcast({
416
- action: 'store',
417
- entry: next,
418
- });
349
+ };
350
+ const store = (value) => {
351
+ return new Promise((res, rej) => {
352
+ const transaction = db.transaction(storeName, 'readwrite');
353
+ const store = transaction.objectStore(storeName);
354
+ store.put(value);
355
+ transaction.oncomplete = () => res();
356
+ transaction.onerror = () => rej(transaction.error);
357
+ // QuotaExceededError surfaces as an abort in some browsers
358
+ transaction.onabort = () => rej(transaction.error);
359
+ }).catch((err) => {
360
+ if (isDevMode())
361
+ console.error('Error storing item in cache DB:', err);
362
+ });
363
+ };
364
+ const remove = (key) => {
365
+ return new Promise((res, rej) => {
366
+ const transaction = db.transaction(storeName, 'readwrite');
367
+ const store = transaction.objectStore(storeName);
368
+ store.delete(key);
369
+ transaction.oncomplete = () => res();
370
+ transaction.onerror = () => rej(transaction.error);
371
+ transaction.onabort = () => rej(transaction.error);
372
+ }).catch((err) => {
373
+ if (isDevMode())
374
+ console.error('Error removing item from cache DB:', err);
375
+ });
376
+ };
377
+ return {
378
+ getAll,
379
+ store,
380
+ remove,
381
+ };
382
+ }
383
+ function createSingleStoreDB(name, getStoreName, version = 1) {
384
+ const storeName = getStoreName(version);
385
+ if (!globalThis.indexedDB)
386
+ return Promise.resolve(createNoopDB());
387
+ return new Promise((res, rej) => {
388
+ if (version < 1) {
389
+ rej(new Error('Version must be 1 or greater'));
390
+ return; // rej does not stop execution — without this, indexedDB.open(name, 0) still runs
419
391
  }
392
+ const req = indexedDB.open(name, version);
393
+ req.onupgradeneeded = (event) => {
394
+ const db = req.result;
395
+ const oldVersion = event.oldVersion;
396
+ db.createObjectStore(storeName, { keyPath: 'key' });
397
+ if (oldVersion > 0) {
398
+ db.deleteObjectStore(getStoreName(oldVersion));
399
+ }
400
+ };
401
+ req.onerror = () => {
402
+ rej(req.error);
403
+ };
404
+ req.onsuccess = () => res(req.result);
405
+ })
406
+ .then((db) => toCacheDB(db, storeName))
407
+ .catch((err) => {
408
+ if (isDevMode())
409
+ console.error('Error creating query DB:', err);
410
+ return createNoopDB();
411
+ });
412
+ }
413
+
414
+ function generateID() {
415
+ if (globalThis.crypto?.randomUUID) {
416
+ return globalThis.crypto.randomUUID();
420
417
  }
418
+ return Math.random().toString(36).substring(2);
419
+ }
420
+ function isSyncMessage(msg) {
421
+ return (typeof msg === 'object' &&
422
+ msg !== null &&
423
+ 'type' in msg &&
424
+ msg.type === 'cache-sync-message');
425
+ }
426
+ /**
427
+ * setTimeout coerces its delay through a signed 32-bit conversion: `Infinity` becomes 0
428
+ * (immediate!) and anything above 2^31-1 ms (~24.8 days) wraps negative. Entries beyond
429
+ * this bound get NO timer and rely on lazy expiry (`expiresAt <= now` checks) plus the
430
+ * periodic sweep instead.
431
+ */
432
+ const MAX_TIMER_DELAY = 2 ** 31 - 1;
433
+ const ONE_DAY = 1000 * 60 * 60 * 24;
434
+ const ONE_HOUR = 1000 * 60 * 60;
435
+ const DEFAULT_CLEANUP_OPT = {
436
+ type: 'lru',
437
+ maxSize: 200,
438
+ checkInterval: ONE_HOUR,
439
+ };
440
+ /**
441
+ * A generic cache implementation that stores data with time-to-live (TTL) and stale-while-revalidate capabilities.
442
+ *
443
+ * @typeParam T - The type of data to be stored in the cache.
444
+ */
445
+ class Cache {
446
+ ttl;
447
+ staleTime;
448
+ db;
449
+ internal = mutable(new Map());
450
+ cleanupOpt;
451
+ id = generateID();
452
+ /** True once async hydration from the persistence layer has completed (or was empty). */
453
+ hydrated = false;
454
+ /** Keys invalidated while hydration was still in flight — must not be resurrected by it. */
455
+ hydrationTombstones = new Set();
456
+ /** Dev-only: ensures the "foreign keys, no matcher" hint in invalidateUrlPrefix fires at most once. */
457
+ warnedForeignKeys = false;
458
+ hitCount = signal(0, /* @ts-ignore */
459
+ ...(ngDevMode ? [{ debugName: "hitCount" }] : /* istanbul ignore next */ []));
460
+ missCount = signal(0, /* @ts-ignore */
461
+ ...(ngDevMode ? [{ debugName: "missCount" }] : /* istanbul ignore next */ []));
421
462
  /**
422
- * Invalidates (removes) a cache entry.
423
- *
424
- * @param key - The key of the entry to invalidate.
463
+ * Read-only cache statistics for debugging/observability — entry count plus
464
+ * request-level hit/miss counters (counted on direct lookups, e.g. the cache
465
+ * interceptor's, not on every reactive signal read). Render it in a debug
466
+ * panel; it intentionally exposes no way to mutate the cache.
425
467
  */
426
- invalidate(key) {
427
- this.invalidateInternal(key);
428
- }
468
+ stats = computed(() => ({
469
+ size: this.internal().size,
470
+ hits: this.hitCount(),
471
+ misses: this.missCount(),
472
+ }), /* @ts-ignore */
473
+ ...(ngDevMode ? [{ debugName: "stats" }] : /* istanbul ignore next */ []));
429
474
  /**
430
- * Invalidates every cache entry whose key starts with `prefix`. Common after a
431
- * list-mutating operation (e.g. invalidate every paginated `GET /api/posts*`
432
- * after a POST). Returns the number of entries removed.
433
- *
434
- * @example
435
- * cache.invalidatePrefix('GET https://api.example.com/posts');
475
+ * Destroys the cache instance, clearing the cleanup interval and closing the
476
+ * cross-tab channel. Called automatically when the providing injector is destroyed
477
+ * (wired up by `provideQueryCache`); call it manually for caches you construct yourself.
436
478
  */
437
- invalidatePrefix(prefix) {
438
- return this.invalidateWhere((key) => key.startsWith(prefix));
439
- }
479
+ destroy;
480
+ broadcast = () => {
481
+ // noop
482
+ };
440
483
  /**
441
- * Invalidates every cache entry whose key matches the predicate. Use for
442
- * arbitrary bulk invalidation that doesn't fit prefix matching (e.g.
443
- * "everything containing `userId=42`"). Returns the number of entries removed.
484
+ * Creates a new `Cache` instance.
444
485
  *
445
- * @example
446
- * cache.invalidateWhere((key) => key.includes('/me/'));
447
- */
448
- invalidateWhere(predicate) {
449
- const keys = Array.from(untracked(this.internal).keys()).filter(predicate);
450
- for (const key of keys)
451
- this.invalidateInternal(key);
452
- return keys.length;
453
- }
454
- invalidateInternal(key, fromSync = false) {
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
- }
466
- if (!fromSync) {
467
- this.db.then((db) => db.remove(key));
468
- this.broadcast({ action: 'invalidate', entry: { key } });
469
- }
470
- }
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.
486
+ * @param ttl - The default Time To Live (TTL) for cache entries, in milliseconds. Defaults to one day.
487
+ * @param staleTime - The default duration, in milliseconds, during which a cache entry is considered
488
+ * stale but can still be used while revalidation occurs in the background. Defaults to 1 hour.
489
+ * @param cleanupOpt - Options for configuring the cache cleanup strategy. Defaults to LRU with a
490
+ * `maxSize` of 200 and a `checkInterval` of one hour.
491
+ * @param syncTabs - If provided, the cache will use the options a BroadcastChannel to send updates between tabs.
492
+ * Defaults to `undefined`, meaning no synchronization across tabs.
474
493
  */
475
- clear() {
476
- for (const key of Array.from(untracked(this.internal).keys())) {
477
- this.invalidateInternal(key);
494
+ constructor(ttl = ONE_DAY, staleTime = ONE_HOUR, cleanupOpt = DEFAULT_CLEANUP_OPT, syncTabs, db = Promise.resolve(createNoopDB())) {
495
+ this.ttl = ttl;
496
+ this.staleTime = staleTime;
497
+ this.db = db;
498
+ this.cleanupOpt = {
499
+ ...DEFAULT_CLEANUP_OPT,
500
+ ...cleanupOpt,
501
+ };
502
+ if (this.cleanupOpt.maxSize <= 0)
503
+ throw new Error('maxSize must be greater than 0');
504
+ // a non-finite checkInterval disables the sweeper entirely (used by the shared NoopCache)
505
+ const cleanupInterval = Number.isFinite(this.cleanupOpt.checkInterval)
506
+ ? setInterval(() => {
507
+ this.cleanup();
508
+ }, this.cleanupOpt.checkInterval)
509
+ : undefined;
510
+ let destroySyncTabs = () => {
511
+ // noop
512
+ };
513
+ if (syncTabs) {
514
+ const channel = new BroadcastChannel(syncTabs.id);
515
+ this.broadcast = (msg) => {
516
+ if (msg.action === 'invalidate')
517
+ return channel.postMessage({
518
+ action: 'invalidate',
519
+ entry: { key: msg.entry.key },
520
+ cacheId: this.id,
521
+ type: 'cache-sync-message',
522
+ });
523
+ return channel.postMessage({
524
+ ...msg,
525
+ entry: {
526
+ ...msg.entry,
527
+ value: syncTabs.serialize(msg.entry.value),
528
+ },
529
+ cacheId: this.id,
530
+ type: 'cache-sync-message',
531
+ });
532
+ };
533
+ channel.onmessage = (event) => {
534
+ const msg = event.data;
535
+ if (!isSyncMessage(msg))
536
+ return;
537
+ if (msg.cacheId === this.id)
538
+ return; // ignore messages from this cache
539
+ if (msg.action === 'store') {
540
+ const value = syncTabs.deserialize(msg.entry.value);
541
+ if (value === null)
542
+ return;
543
+ // Last-write-wins by `updated` timestamp.
544
+ const existing = untracked(this.internal).get(msg.entry.key);
545
+ if (existing && existing.updated >= msg.entry.updated)
546
+ return;
547
+ this.restoreInternal({ ...msg.entry, value });
548
+ }
549
+ else if (msg.action === 'invalidate') {
550
+ this.invalidateInternal(msg.entry.key, true);
551
+ }
552
+ };
553
+ destroySyncTabs = () => {
554
+ channel.close();
555
+ };
478
556
  }
479
- }
480
- /** @internal Drops expired entries, then enforces `maxSize` by the configured strategy. */
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;
557
+ let destroyed = false;
558
+ const destroy = () => {
559
+ if (destroyed)
560
+ return;
561
+ destroyed = true;
562
+ if (cleanupInterval !== undefined)
563
+ clearInterval(cleanupInterval);
564
+ destroySyncTabs();
565
+ };
566
+ this.db
567
+ .then(async (db) => {
568
+ if (destroyed)
569
+ return [];
570
+ return db.getAll();
571
+ })
572
+ .then((entries) => {
573
+ if (destroyed)
574
+ return;
575
+ const current = untracked(this.internal);
576
+ entries.forEach((entry) => {
577
+ if (current.has(entry.key))
578
+ return;
579
+ // a key invalidated while hydration was in flight must stay dead
580
+ if (this.hydrationTombstones.has(entry.key))
581
+ return;
582
+ this.restoreInternal(entry);
491
583
  });
492
- }
493
- if (untracked(this.internal).size <= this.cleanupOpt.maxSize)
494
- return;
495
- const sorted = Array.from(untracked(this.internal).entries()).toSorted((a, b) => {
496
- if (this.cleanupOpt.type === 'lru') {
497
- return a[1].lastAccessed - b[1].lastAccessed; // least recently accessed first
498
- }
499
- else {
500
- return a[1].created - b[1].created; // oldest first
501
- }
502
- });
503
- const keepCount = Math.max(1, Math.floor(this.cleanupOpt.maxSize / 2));
504
- const removed = sorted.slice(0, sorted.length - keepCount);
505
- const keep = sorted.slice(removed.length, sorted.length);
506
- removed.forEach(([, e]) => {
507
- clearTimeout(e.timeout);
584
+ this.hydrated = true;
585
+ this.hydrationTombstones.clear();
508
586
  });
509
- this.internal.set(new Map(keep));
587
+ this.destroy = destroy;
510
588
  }
511
- }
512
- const CLIENT_CACHE_TOKEN = new InjectionToken('INTERNAL_CLIENT_CACHE');
513
- /**
514
- * Provides the instance of the QueryCache for queryResource. This should probably be called
515
- * in your application's root configuration, but can also be overriden with component/module providers.
516
- *
517
- * @param options - Optional configuration options for the cache.
518
- * @returns An Angular `Provider` for the cache.
519
- *
520
- * @example
521
- * // In your app.config.ts or AppModule providers:
522
- *
523
- * import { provideQueryCache } from './your-cache';
524
- *
525
- * export const appConfig: ApplicationConfig = {
526
- * providers: [
527
- * provideQueryCache({
528
- * ttl: 60000, // Default TTL of 60 seconds
529
- * staleTime: 30000, // Default staleTime of 30 seconds
530
- * }),
531
- * // ... other providers
532
- * ]
533
- * };
534
- */
535
- function provideQueryCache(opt) {
536
- const serialize = (value) => {
537
- const headersRecord = {};
538
- const headerKeys = value.headers.keys();
539
- headerKeys.forEach((key) => {
540
- const values = value.headers.getAll(key);
541
- if (!values)
542
- return;
543
- headersRecord[key] = values;
544
- });
545
- return JSON.stringify({
546
- body: value.body,
547
- status: value.status,
548
- // statusText intentionally omitted: deprecated in Angular, meaningless under
549
- // HTTP/2+ (HttpResponse defaults it to 'OK' on reconstruction)
550
- headers: headerKeys.length > 0 ? headersRecord : undefined,
551
- url: value.url,
552
- });
553
- };
554
- const deserialize = (value) => {
555
- try {
556
- const parsed = JSON.parse(value);
557
- if (!parsed || typeof parsed !== 'object' || !('body' in parsed))
558
- throw new Error('Invalid cache entry format');
559
- const headers = parsed.headers
560
- ? new HttpHeaders(parsed.headers)
561
- : undefined;
562
- return new HttpResponse({
563
- body: parsed.body,
564
- status: parsed.status,
565
- headers: headers,
566
- url: parsed.url,
567
- });
568
- }
569
- catch (err) {
570
- if (isDevMode())
571
- console.error('Failed to deserialize cache entry:', err);
572
- return null;
573
- }
574
- };
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}`;
578
- return {
579
- provide: CLIENT_CACHE_TOKEN,
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
- },
621
- };
622
- }
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,
589
+ /** @internal */
590
+ getInternal(key) {
591
+ const keySignal = computed(() => key(), /* @ts-ignore */
592
+ ...(ngDevMode ? [{ debugName: "keySignal" }] : /* istanbul ignore next */ []));
593
+ return computed(() => {
594
+ const key = keySignal();
595
+ if (!key)
596
+ return null;
597
+ const found = this.internal().get(key);
598
+ const now = Date.now();
599
+ if (!found || found.expiresAt <= now)
600
+ return null;
601
+ return {
602
+ ...found,
603
+ isStale: found.stale <= now,
604
+ };
605
+ }, {
606
+ equal: (a, b) => a === b ||
607
+ (!!a &&
608
+ !!b &&
609
+ a.key === b.key &&
610
+ a.value === b.value &&
611
+ a.updated === b.updated &&
612
+ a.isStale === b.isStale),
631
613
  });
632
614
  }
633
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
634
- store(_, __, ___ = super.staleTime, ____ = super.ttl) {
635
- // noop
615
+ /** @internal Imperative access bookkeeping for LRU eviction. */
616
+ touch(entry) {
617
+ entry.lastAccessed = Date.now();
618
+ entry.useCount++;
636
619
  }
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;
641
- /**
642
- * Injects the `QueryCache` instance that is used within queryResource.
643
- * Allows for direct modification of cached data, but is mostly meant for internal use.
644
- *
645
- * @param injector - (Optional) The injector to use. If not provided, the current
646
- * injection context is used.
647
- * @returns The `QueryCache` instance.
648
- *
649
- * @example
650
- * // In your component or service:
651
- *
652
- * import { injectQueryCache } from './your-cache';
653
- *
654
- * constructor() {
655
- * const cache = injectQueryCache();
656
- *
657
- * const myData = cache.get(() => 'my-data-key');
658
- * if (myData() !== null) {
659
- * // ... use cached data ...
660
- * }
661
- * }
662
- */
663
- function injectQueryCache(injector) {
664
- const cache = injector
665
- ? injector.get(CLIENT_CACHE_TOKEN, null, {
666
- optional: true,
667
- })
668
- : inject(CLIENT_CACHE_TOKEN, {
669
- optional: true,
620
+ /**
621
+ * Retrieves a cache entry directly (non-reactively), updating its access bookkeeping
622
+ * for LRU eviction.
623
+ * @internal
624
+ * @param key - The key of the entry to retrieve.
625
+ * @returns The cache entry, or `null` if not found or expired.
626
+ */
627
+ getUntracked(key) {
628
+ const found = untracked(this.internal).get(key);
629
+ const now = Date.now();
630
+ if (!found || found.expiresAt <= now) {
631
+ this.missCount.update((c) => c + 1);
632
+ return null;
633
+ }
634
+ this.touch(found);
635
+ this.hitCount.update((c) => c + 1);
636
+ return {
637
+ ...found,
638
+ isStale: found.stale <= now,
639
+ };
640
+ }
641
+ /**
642
+ * Retrieves a cache entry as a signal.
643
+ *
644
+ * @param key - A function that returns the cache key. The key is a signal, allowing for dynamic keys. If the function returns null the value is also null.
645
+ * @returns A signal that holds the cache entry, or `null` if not found or expired. The signal
646
+ * updates whenever the cache entry changes (e.g., due to revalidation or expiration).
647
+ */
648
+ get(key) {
649
+ return this.getInternal(key);
650
+ }
651
+ /**
652
+ * Retrieves a cache entry or an object with the key if not found.
653
+ *
654
+ * @param key - A function that returns the cache key. The key is a signal, allowing for dynamic keys. If the function returns null the value is also null.
655
+ * @returns A signal that holds the cache entry or an object with the key if not found. The signal
656
+ * updates whenever the cache entry changes (e.g., due to revalidation or expiration).
657
+ */
658
+ getEntryOrKey(key) {
659
+ const valueSig = this.getInternal(key);
660
+ return computed(() => valueSig() ?? key());
661
+ }
662
+ /**
663
+ * Stores a value in the cache.
664
+ *
665
+ * NOTE: cached values are shared by reference across all consumers (current and
666
+ * future cache hits, persistence, cross-tab sync) — do not mutate a value after
667
+ * storing it or after reading it from the cache.
668
+ *
669
+ * @param key - The key under which to store the value.
670
+ * @param value - The value to store.
671
+ * @param staleTime - (Optional) The stale time for this entry, in milliseconds. Overrides the default `staleTime`.
672
+ * @param ttl - (Optional) The TTL for this entry, in milliseconds. Overrides the default `ttl`.
673
+ * @param persist - (Optional) Whether to also write the entry to the persistence layer (IndexedDB). Defaults to `false`.
674
+ */
675
+ store(key, value, staleTime = this.staleTime, ttl = this.ttl, persist = false) {
676
+ this.storeInternal(key, value, staleTime, ttl, false, persist);
677
+ }
678
+ storeInternal(key, value, staleTime = this.staleTime, ttl = this.ttl, fromSync = false, persist = false) {
679
+ const entry = untracked(this.internal).get(key);
680
+ // ttl cannot be less than staleTime
681
+ if (ttl < staleTime)
682
+ staleTime = ttl;
683
+ const now = Date.now();
684
+ this.setEntry({
685
+ value,
686
+ created: entry?.created ?? now,
687
+ updated: now,
688
+ useCount: (entry?.useCount ?? 0) + 1,
689
+ lastAccessed: now,
690
+ stale: now + staleTime,
691
+ expiresAt: now + ttl,
692
+ key,
693
+ }, fromSync, persist);
694
+ }
695
+ /**
696
+ * @internal
697
+ * Inserts an entry that already carries ABSOLUTE timestamps — hydration from the
698
+ * persistence layer and cross-tab sync messages. Never re-anchors freshness to
699
+ * `Date.now()`, never persists, never broadcasts.
700
+ */
701
+ restoreInternal(entry) {
702
+ this.setEntry({
703
+ ...entry,
704
+ // rows persisted by older versions may lack the field
705
+ lastAccessed: entry.lastAccessed ?? entry.updated,
706
+ }, true, false);
707
+ }
708
+ /** @internal Shared writer: arms the expiry timer only within the safe delay range. */
709
+ setEntry(next, fromSync, persist) {
710
+ const existing = untracked(this.internal).get(next.key);
711
+ if (existing)
712
+ clearTimeout(existing.timeout); // stop the previous invalidation
713
+ const remaining = next.expiresAt - Date.now();
714
+ // already expired (clock skew on a synced/restored entry) — don't insert
715
+ if (remaining <= 0)
716
+ return;
717
+ // Infinity (immutable) or > 2^31-1 would coerce to an IMMEDIATE timeout — such
718
+ // entries get no timer and rely on lazy expiry + the periodic sweep instead
719
+ const timeout = Number.isFinite(remaining) && remaining <= MAX_TIMER_DELAY
720
+ ? setTimeout(() => this.invalidate(next.key), remaining)
721
+ : undefined;
722
+ this.internal.mutate((map) => {
723
+ map.set(next.key, { ...next, timeout });
724
+ return map;
670
725
  });
671
- if (!cache) {
672
- if (isDevMode())
673
- throw new Error('Cache not provided, please add provideQueryCache() to providers array');
674
- else
675
- return (NOOP_CACHE ??= new NoopCache());
726
+ if (!fromSync) {
727
+ if (persist)
728
+ this.db.then((db) => db.store(next));
729
+ this.broadcast({
730
+ action: 'store',
731
+ entry: next,
732
+ });
733
+ }
676
734
  }
677
- return cache;
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
- }
690
-
691
- /**
692
- * Returns `true` for any object-like value whose own enumerable keys should
693
- * be sorted for stable hashing. Excludes arrays (positional), `Date`
694
- * (handled by `toJSON`), `Map`/`Set` (handled explicitly), and binary types
695
- * (`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`/typed arrays — these
696
- * should be branched on before reaching `hash()`, typically by `hashRequest`).
697
- *
698
- * Plain objects, class instances, and `Object.create(null)` all qualify.
699
- */
700
- function isHashableObject(value) {
701
- if (value === null || typeof value !== 'object')
702
- return false;
703
- if (Array.isArray(value))
704
- return false;
705
- if (value instanceof Date)
706
- return false;
707
- if (value instanceof Map)
708
- return false;
709
- if (value instanceof Set)
710
- return false;
711
- if (typeof Blob !== 'undefined' && value instanceof Blob)
712
- return false;
713
- if (typeof FormData !== 'undefined' && value instanceof FormData)
714
- return false;
715
- if (typeof URLSearchParams !== 'undefined' &&
716
- value instanceof URLSearchParams)
717
- return false;
718
- if (value instanceof ArrayBuffer)
719
- return false;
720
- if (ArrayBuffer.isView(value))
721
- return false;
722
- return true;
723
- }
724
- function sortKeys(val) {
725
- return Object.keys(val)
726
- .toSorted()
727
- .reduce((result, key) => {
728
- result[key] = val[key];
729
- return result;
730
- }, {});
731
- }
732
- /**
733
- * Internal helper to generate a stable JSON string from an array.
734
- * - Object-like values (plain, class instances, null-proto) get their own
735
- * enumerable keys sorted alphabetically.
736
- * - `Map` → marker object with sorted entries (sorted by `JSON.stringify(key)`).
737
- * - `Set` → marker object with sorted values (sorted by `JSON.stringify(value)`).
738
- * - Arrays preserve order. `Date` serializes via `toJSON`.
739
- *
740
- * @internal
741
- */
742
- function hashKey(queryKey) {
743
- return JSON.stringify(queryKey, (_, val) => {
744
- if (val instanceof Map) {
745
- // Schwartzian: compute each entry's sort key (recursive hash of the
746
- // Map key) once, then sort by the cheap string compare.
747
- const entries = [...val.entries()]
748
- .map((e) => [hash(e[0]), e])
749
- .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
750
- .map(([, e]) => e);
751
- return { __map__: entries };
735
+ /**
736
+ * Invalidates (removes) a cache entry.
737
+ *
738
+ * @param key - The key of the entry to invalidate.
739
+ */
740
+ invalidate(key) {
741
+ this.invalidateInternal(key);
742
+ }
743
+ /**
744
+ * Invalidates every cache entry whose key starts with `prefix`. Common after a
745
+ * list-mutating operation (e.g. invalidate every paginated `GET /api/posts*`
746
+ * after a POST). Returns the number of entries removed.
747
+ *
748
+ * @example
749
+ * cache.invalidatePrefix('GET https://api.example.com/posts');
750
+ */
751
+ invalidatePrefix(prefix) {
752
+ return this.invalidateWhere((key) => key.startsWith(prefix));
753
+ }
754
+ /**
755
+ * Invalidates every cache entry whose *request URL* starts with `urlPrefix`,
756
+ * regardless of HTTP method. This is the engine behind `mutationResource`'s
757
+ * `invalidates` option: `'/api/posts'` clears `/api/posts` with any query
758
+ * params, subpaths like `/api/posts/123`, and all `varyHeaders` variants —
759
+ * across GET/HEAD/OPTIONS/POST or any other cached method. Returns the number
760
+ * of entries removed.
761
+ *
762
+ * Unlike {@link invalidatePrefix} (which matches the raw key from its start),
763
+ * this extracts the URL field from the auto-generated key shape, so it is not
764
+ * fooled by the leading method token nor by a namespace a custom `cache.hash`
765
+ * prepends (e.g. `tenant:…`). Plain prefix matching still catches siblings
766
+ * sharing the prefix (`/api/posts-archive`) — pass `'/api/posts/'` to narrow.
767
+ *
768
+ * Keys produced by a custom `hash` that don't follow the auto shape won't be
769
+ * matched by the default; pass `match` to describe how a URL prefix maps onto
770
+ * your key format. In dev mode, if a default-matcher call removes nothing and
771
+ * every cached key is foreign-shaped, this logs a one-time hint pointing at the
772
+ * `match` escape hatch (a likely sign of a custom `hash` with no matcher wired up).
773
+ *
774
+ * @param urlPrefix - URL prefix to match.
775
+ * @param match - Optional custom matcher: given the prefix, returns a key predicate.
776
+ *
777
+ * @example
778
+ * cache.invalidateUrlPrefix('/api/posts');
779
+ * // custom key scheme:
780
+ * cache.invalidateUrlPrefix('/api/posts', (p) => (k) => k.includes(`|url=${p}`));
781
+ */
782
+ invalidateUrlPrefix(urlPrefix, match) {
783
+ if (match)
784
+ return this.invalidateWhere(match(urlPrefix));
785
+ let sawAutoKey = false;
786
+ const removed = this.invalidateWhere((key) => {
787
+ const url = extractUrlFromKey(key);
788
+ if (url === null)
789
+ return false; // foreign-shaped key
790
+ sawAutoKey = true;
791
+ return url.startsWith(urlPrefix);
792
+ });
793
+ if (isDevMode() &&
794
+ !this.warnedForeignKeys &&
795
+ removed === 0 &&
796
+ !sawAutoKey &&
797
+ untracked(this.internal).size > 0) {
798
+ this.warnedForeignKeys = true;
799
+ console.warn(`[@mmstack/resource] invalidateUrlPrefix('${urlPrefix}') matched nothing, and no cached key follows the default key shape. If you use a custom 'cache.hash', pass an 'invalidateMatcher' (mutationResource) / 'match' (invalidateUrlPrefix) so invalidation can locate your keys.`);
752
800
  }
753
- if (val instanceof Set) {
754
- const values = [...val]
755
- .map((v) => [hash(v), v])
756
- .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
757
- .map(([, v]) => v);
758
- return { __set__: values };
801
+ return removed;
802
+ }
803
+ /**
804
+ * Invalidates every cache entry whose key matches the predicate. Use for
805
+ * arbitrary bulk invalidation that doesn't fit prefix matching (e.g.
806
+ * "everything containing `userId=42`"). Returns the number of entries removed.
807
+ *
808
+ * @example
809
+ * cache.invalidateWhere((key) => key.includes('/me/'));
810
+ */
811
+ invalidateWhere(predicate) {
812
+ const keys = Array.from(untracked(this.internal).keys()).filter(predicate);
813
+ for (const key of keys)
814
+ this.invalidateInternal(key);
815
+ return keys.length;
816
+ }
817
+ invalidateInternal(key, fromSync = false) {
818
+ // a key invalidated before async hydration completes must not be resurrected by it
819
+ if (!this.hydrated)
820
+ this.hydrationTombstones.add(key);
821
+ const entry = untracked(this.internal).get(key);
822
+ if (entry) {
823
+ clearTimeout(entry.timeout);
824
+ this.internal.mutate((map) => {
825
+ map.delete(key);
826
+ return map;
827
+ });
759
828
  }
760
- if (isHashableObject(val))
761
- return sortKeys(val);
762
- return val;
763
- });
829
+ if (!fromSync) {
830
+ this.db.then((db) => db.remove(key));
831
+ this.broadcast({ action: 'invalidate', entry: { key } });
832
+ }
833
+ }
834
+ /**
835
+ * Removes EVERY entry — memory, persisted rows, and (via broadcast) other tabs.
836
+ * Call on logout/auth changes so no prior user's responses survive.
837
+ */
838
+ clear() {
839
+ for (const key of Array.from(untracked(this.internal).keys())) {
840
+ this.invalidateInternal(key);
841
+ }
842
+ }
843
+ /** @internal Drops expired entries, then enforces `maxSize` by the configured strategy. */
844
+ cleanup() {
845
+ const now = Date.now();
846
+ // expired entries first — their timers may never have fired (throttled background
847
+ // tabs, or timer-less long-TTL entries)
848
+ const expired = Array.from(untracked(this.internal).entries()).filter(([, e]) => e.expiresAt <= now);
849
+ if (expired.length) {
850
+ expired.forEach(([, e]) => clearTimeout(e.timeout));
851
+ this.internal.mutate((map) => {
852
+ expired.forEach(([key]) => map.delete(key));
853
+ return map;
854
+ });
855
+ }
856
+ if (untracked(this.internal).size <= this.cleanupOpt.maxSize)
857
+ return;
858
+ const sorted = Array.from(untracked(this.internal).entries()).toSorted((a, b) => {
859
+ if (this.cleanupOpt.type === 'lru') {
860
+ return a[1].lastAccessed - b[1].lastAccessed; // least recently accessed first
861
+ }
862
+ else {
863
+ return a[1].created - b[1].created; // oldest first
864
+ }
865
+ });
866
+ const keepCount = Math.max(1, Math.floor(this.cleanupOpt.maxSize / 2));
867
+ const removed = sorted.slice(0, sorted.length - keepCount);
868
+ const keep = sorted.slice(removed.length, sorted.length);
869
+ removed.forEach(([, e]) => {
870
+ clearTimeout(e.timeout);
871
+ });
872
+ this.internal.set(new Map(keep));
873
+ }
764
874
  }
875
+ const CLIENT_CACHE_TOKEN = new InjectionToken('INTERNAL_CLIENT_CACHE');
765
876
  /**
766
- * Generates a stable, unique string hash from one or more arguments.
767
- * Useful for creating cache keys or identifiers where object key order shouldn't matter.
877
+ * Provides the instance of the QueryCache for queryResource. This should probably be called
878
+ * in your application's root configuration, but can also be overriden with component/module providers.
768
879
  *
769
- * How it works:
770
- * - Object-like values (plain objects, class instances, `Object.create(null)`) have
771
- * their own enumerable keys sorted alphabetically before hashing. This ensures
772
- * `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same hash.
773
- * - `Map` and `Set` are serialized via stable, sorted markers (`__map__` / `__set__`).
774
- * - Arrays preserve positional order; `Date` uses its ISO string via `toJSON`.
880
+ * @param options - Optional configuration options for the cache.
881
+ * @returns An Angular `Provider` for the cache.
775
882
  *
776
- * @param {...unknown} args Values to include in the hash.
777
- * @returns A stable string hash representing the input arguments.
778
883
  * @example
779
- * hash('posts', 10);
780
- * // => '["posts",10]'
781
- *
782
- * hash({ a: 1, b: 2 }) === hash({ b: 2, a: 1 }); // true
884
+ * // In your app.config.ts or AppModule providers:
783
885
  *
784
- * hash(new Map([['a', 1]])) === hash(new Map([['a', 1]])); // true
886
+ * import { provideQueryCache } from './your-cache';
785
887
  *
786
- * // Be mindful of values JSON.stringify cannot handle (functions, undefined, Symbols)
787
- * // hash('a', undefined, function() {}) => '["a",null,null]'
788
- */
789
- function hash(...args) {
790
- return hashKey(args);
791
- }
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.
888
+ * export const appConfig: ApplicationConfig = {
889
+ * providers: [
890
+ * provideQueryCache({
891
+ * ttl: 60000, // Default TTL of 60 seconds
892
+ * staleTime: 30000, // Default staleTime of 30 seconds
893
+ * }),
894
+ * // ... other providers
895
+ * ]
896
+ * };
837
897
  */
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);
898
+ function provideQueryCache(opt) {
899
+ const serialize = (value) => {
900
+ const headersRecord = {};
901
+ const headerKeys = value.headers.keys();
902
+ headerKeys.forEach((key) => {
903
+ const values = value.headers.getAll(key);
904
+ if (!values)
905
+ return;
906
+ headersRecord[key] = values;
907
+ });
908
+ return JSON.stringify({
909
+ body: value.body,
910
+ status: value.status,
911
+ // statusText intentionally omitted: deprecated in Angular, meaningless under
912
+ // HTTP/2+ (HttpResponse defaults it to 'OK' on reconstruction)
913
+ headers: headerKeys.length > 0 ? headersRecord : undefined,
914
+ url: value.url,
915
+ });
916
+ };
917
+ const deserialize = (value) => {
918
+ try {
919
+ const parsed = JSON.parse(value);
920
+ if (!parsed || typeof parsed !== 'object' || !('body' in parsed))
921
+ throw new Error('Invalid cache entry format');
922
+ const headers = parsed.headers
923
+ ? new HttpHeaders(parsed.headers)
924
+ : undefined;
925
+ return new HttpResponse({
926
+ body: parsed.body,
927
+ status: parsed.status,
928
+ headers: headers,
929
+ url: parsed.url,
930
+ });
931
+ }
932
+ catch (err) {
933
+ if (isDevMode())
934
+ console.error('Failed to deserialize cache entry:', err);
935
+ return null;
884
936
  }
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
- }
896
- function normalizeParams(params) {
897
- const p = params instanceof HttpParams
898
- ? params
899
- : new HttpParams({ fromObject: params });
900
- return p
901
- .keys()
902
- .toSorted()
903
- .map((key) => {
904
- const encodedKey = encodeURIComponent(key);
905
- return (p.getAll(key) ?? [])
906
- .map((v) => `${encodedKey}=${encodeURIComponent(v)}`)
907
- .join('&');
908
- })
909
- .join('&');
937
+ };
938
+ // version-suffixed so two deploys with incompatible schemas in adjacent tabs don't
939
+ // push entries into each other's caches (the `version` option only fences IndexedDB)
940
+ const syncChannelId = `mmstack-query-cache-sync_v${opt?.version ?? 1}`;
941
+ return {
942
+ provide: CLIENT_CACHE_TOKEN,
943
+ useFactory: () => {
944
+ const onServer = inject(PLATFORM_ID) === 'server';
945
+ // no IndexedDB / BroadcastChannel on the server — each request gets an
946
+ // isolated, request-lived, memory-only cache
947
+ const syncTabsOpt = !onServer && opt?.syncTabs
948
+ ? {
949
+ id: syncChannelId,
950
+ serialize,
951
+ deserialize,
952
+ }
953
+ : undefined;
954
+ const db = onServer || opt?.persist === false
955
+ ? undefined
956
+ : createSingleStoreDB('mmstack-query-cache-db', (version) => `query-store_v${version}`, opt?.version).then((db) => {
957
+ return {
958
+ getAll: () => {
959
+ return db.getAll().then((entries) => {
960
+ return entries
961
+ .map((entry) => {
962
+ const value = deserialize(entry.value);
963
+ if (value === null)
964
+ return null;
965
+ return {
966
+ ...entry,
967
+ value,
968
+ };
969
+ })
970
+ .filter((e) => e !== null);
971
+ });
972
+ },
973
+ store: (entry) => {
974
+ return db.store({ ...entry, value: serialize(entry.value) });
975
+ },
976
+ remove: db.remove,
977
+ };
978
+ });
979
+ const cache = new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup, syncTabsOpt, db);
980
+ // release the sweep interval / channel with the providing injector
981
+ inject(DestroyRef, { optional: true })?.onDestroy(() => cache.destroy());
982
+ return cache;
983
+ },
984
+ };
910
985
  }
911
- function hashBody(body) {
912
- // File extends Blob — must check File first
913
- if (typeof File !== 'undefined' && body instanceof File) {
914
- return `File:${body.name}:${body.type}:${body.size}:${body.lastModified}`;
915
- }
916
- if (typeof Blob !== 'undefined' && body instanceof Blob) {
917
- return `Blob:${body.type}:${body.size}`;
918
- }
919
- if (typeof FormData !== 'undefined' && body instanceof FormData) {
920
- const entries = [];
921
- body.forEach((value, key) => {
922
- entries.push([key, hashBody(value)]);
986
+ class NoopCache extends Cache {
987
+ constructor() {
988
+ // Infinity checkInterval no sweep interval is ever armed, so the shared
989
+ // instance below never pins a timer
990
+ super(undefined, undefined, {
991
+ type: 'lru',
992
+ maxSize: 200,
993
+ checkInterval: Infinity,
923
994
  });
924
- entries.sort(([ak, av], [bk, bv]) => ak.localeCompare(bk) || av.localeCompare(bv));
925
- return `FormData:${entries.map(([k, v]) => `${k}=${v}`).join('&')}`;
926
- }
927
- if (typeof URLSearchParams !== 'undefined' &&
928
- body instanceof URLSearchParams) {
929
- const sp = new URLSearchParams(body);
930
- sp.sort();
931
- return `URLSearchParams:${sp.toString()}`;
932
995
  }
933
- if (body instanceof ArrayBuffer) {
934
- return `ArrayBuffer:${body.byteLength}`;
996
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
997
+ store(_, __, ___ = super.staleTime, ____ = super.ttl) {
998
+ // noop
935
999
  }
936
- if (ArrayBuffer.isView(body)) {
937
- return `${body.constructor.name}:${body.byteLength}`;
1000
+ }
1001
+ // one shared instance — minting a NoopCache per injectQueryCache() miss would leak
1002
+ // an instance (and previously an interval) on every prod call without a provider
1003
+ let NOOP_CACHE;
1004
+ /**
1005
+ * Injects the `QueryCache` instance that is used within queryResource.
1006
+ * Allows for direct modification of cached data, but is mostly meant for internal use.
1007
+ *
1008
+ * @param injector - (Optional) The injector to use. If not provided, the current
1009
+ * injection context is used.
1010
+ * @returns The `QueryCache` instance.
1011
+ *
1012
+ * @example
1013
+ * // In your component or service:
1014
+ *
1015
+ * import { injectQueryCache } from './your-cache';
1016
+ *
1017
+ * constructor() {
1018
+ * const cache = injectQueryCache();
1019
+ *
1020
+ * const myData = cache.get(() => 'my-data-key');
1021
+ * if (myData() !== null) {
1022
+ * // ... use cached data ...
1023
+ * }
1024
+ * }
1025
+ */
1026
+ function injectQueryCache(injector) {
1027
+ const cache = injector
1028
+ ? injector.get(CLIENT_CACHE_TOKEN, null, {
1029
+ optional: true,
1030
+ })
1031
+ : inject(CLIENT_CACHE_TOKEN, {
1032
+ optional: true,
1033
+ });
1034
+ if (!cache) {
1035
+ if (isDevMode())
1036
+ throw new Error('Cache not provided, please add provideQueryCache() to providers array');
1037
+ else
1038
+ return (NOOP_CACHE ??= new NoopCache());
938
1039
  }
939
- return hash(body);
1040
+ return cache;
940
1041
  }
941
1042
  /**
942
- * Builds a stable cache/dedupe key from an HTTP request shape (accepts both
943
- * `HttpRequest` and `HttpResourceRequest`).
1043
+ * Injects the cache statistics, including the current size of the cache and the number of hits and misses.
944
1044
  *
945
- * Key composition: `${method}:${url}:${responseType}[:${params}][:${body}][:${vary}]`
946
- * - `method` defaults to `'GET'`, `responseType` to `'json'` (Angular defaults).
947
- * - Query params are sorted alphabetically and URL-encoded for stability.
948
- * - Body hashing handles `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`
949
- * and typed arrays explicitly; everything else flows through key-sorted
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.
1045
+ * @param injector - (Optional) The injector to use. If not provided, the current
1046
+ * injection context is used.
1047
+ * @returns A signal containing the cache statistics.
957
1048
  */
958
- function hashRequest(req, varyHeaders) {
959
- const method = req.method ?? 'GET';
960
- const responseType = req.responseType ?? 'json';
961
- const base = `${method}:${req.url}:${responseType}`;
962
- const params = req.params ? `:${normalizeParams(req.params)}` : '';
963
- const body = req.body != null ? `:${hashBody(req.body)}` : '';
964
- const vary = varyHeaders?.length
965
- ? `:vary(${normalizeVaryHeaders(req.headers, varyHeaders)})`
966
- : '';
967
- return base + params + body + vary;
1049
+ function injectCacheStats(injector) {
1050
+ const cache = injectQueryCache(injector);
1051
+ return cache.stats;
968
1052
  }
969
1053
 
970
1054
  /**
@@ -1853,10 +1937,10 @@ function retryOnError(res, opt, onError) {
1853
1937
  class ResourceSensors {
1854
1938
  networkStatus = sensor('networkStatus');
1855
1939
  pageVisibility = sensor('pageVisibility');
1856
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: ResourceSensors, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1857
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: ResourceSensors, providedIn: 'root' });
1940
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ResourceSensors, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1941
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ResourceSensors, providedIn: 'root' });
1858
1942
  }
1859
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: ResourceSensors, decorators: [{
1943
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ResourceSensors, decorators: [{
1860
1944
  type: Injectable,
1861
1945
  args: [{
1862
1946
  providedIn: 'root',
@@ -2377,6 +2461,23 @@ function manualQueryResource(request, options) {
2377
2461
  }
2378
2462
 
2379
2463
  const NULL_VALUE = Symbol('@mmstack/resource:null');
2464
+ /**
2465
+ * Rejection reason for a {@link MutationResourceRef.mutateAsync} promise whose
2466
+ * mutation never completed. The {@link MutationCancelledError.type} discriminant
2467
+ * carries the cause ({@link MutationCancellationReason}); the message is a
2468
+ * human-readable elaboration of it.
2469
+ *
2470
+ * Only `mutateAsync` promises reject with this; plain `mutate()` calls have no
2471
+ * promise and so produce no (potentially unhandled) rejection.
2472
+ */
2473
+ class MutationCancelledError extends Error {
2474
+ type;
2475
+ constructor(type, message) {
2476
+ super(message);
2477
+ this.type = type;
2478
+ this.name = 'MutationCancelledError';
2479
+ }
2480
+ }
2380
2481
  const MUTATION_RESOURCE_OPTIONS = new InjectionToken('@mmstack/resource:mutation-resource-options', { factory: () => ({}) });
2381
2482
  /**
2382
2483
  * Layer 2 (mutation): default options for every `mutationResource`, inheriting + overriding the
@@ -2390,55 +2491,6 @@ function injectMutationResourceOptions(injector) {
2390
2491
  ? injector.get(MUTATION_RESOURCE_OPTIONS)
2391
2492
  : inject(MUTATION_RESOURCE_OPTIONS);
2392
2493
  }
2393
- /**
2394
- * Creates a resource for performing mutations (e.g., POST, PUT, PATCH, DELETE requests).
2395
- * Unlike `queryResource`, `mutationResource` is designed for one-off operations that change data.
2396
- * It does *not* cache responses and does not provide a `value` signal. Instead, it focuses on
2397
- * managing the mutation lifecycle (pending, error, success) and provides callbacks for handling
2398
- * these states.
2399
- *
2400
- * @param request A function that returns the base `HttpResourceRequest` to be made. This function is called reactively. The parameter is the mutation value provided by the `mutate` method.
2401
- * @param options Configuration options for the mutation resource. This includes callbacks
2402
- * for `onMutate`, `onError`, `onSuccess`, and `onSettled`.
2403
- * @typeParam TResult - The type of the expected result from the mutation.
2404
- * @typeParam TRaw - The raw response type from the HTTP request (defaults to TResult).
2405
- * @typeParam TMutation - The type of the mutation value (the request body).
2406
- * @typeParam TICTX - The type of the initial context value passed to `onMutate`.
2407
- * @typeParam TCTX - The type of the context value returned by `onMutate`.
2408
- * @typeParam TMethod - The HTTP method to be used for the mutation (defaults to `HttpResourceRequest['method']`).
2409
- * @returns A `MutationResourceRef` instance, which provides methods for triggering the mutation
2410
- * and observing its status.
2411
- *
2412
- * @example
2413
- * ```ts
2414
- * // Basic PATCH mutation
2415
- * const updateUser = mutationResource<User, User, Partial<User>>(
2416
- * (body) => ({ url: `/api/users/${userId()}`, method: 'PATCH', body }),
2417
- * {
2418
- * onSuccess: (saved) => toast.success(`Updated ${saved.name}`),
2419
- * onError: (err) => toast.error(err),
2420
- * },
2421
- * );
2422
- *
2423
- * updateUser.mutate({ name: 'Alice' });
2424
- * ```
2425
- *
2426
- * @example
2427
- * ```ts
2428
- * // Optimistic update with rollback via the `ctx` returned from `onMutate`
2429
- * const updateUser = mutationResource<User, User, Partial<User>, { prev: User | null }>(
2430
- * (body) => ({ url: `/api/users/${userId()}`, method: 'PATCH', body }),
2431
- * {
2432
- * onMutate: (patch) => {
2433
- * const prev = current();
2434
- * current.update((u) => (u ? { ...u, ...patch } : u));
2435
- * return { prev };
2436
- * },
2437
- * onError: (_err, { prev }) => current.set(prev),
2438
- * },
2439
- * );
2440
- * ```
2441
- */
2442
2494
  function mutationResource(request, options0 = {}) {
2443
2495
  // Two-layer option injection: per-call > provideMutationResourceOptions > provideResourceOptions.
2444
2496
  const globalOpts = injectResourceOptions(options0.injector);
@@ -2450,16 +2502,9 @@ function mutationResource(request, options0 = {}) {
2450
2502
  circuitBreaker: mergeCircuitBreakerOptions(globalOpts.circuitBreaker, mutOpts.circuitBreaker, options0?.circuitBreaker),
2451
2503
  retry: mergeRetryOptions(globalOpts.retry, mutOpts.retry, options0?.retry),
2452
2504
  };
2453
- // `register` is pulled out (and forced off on the inner query below) so the mutation ref is
2454
- // the only thing registered into the transition scope, not its internal query resource.
2455
- const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, invalidates, ...rest } = options;
2505
+ const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, invalidates, invalidateMatcher, ...rest } = options;
2456
2506
  const cache = invalidates ? injectQueryCache(options.injector) : undefined;
2457
2507
  const requestEqual = equalRequest ?? createEqualRequest(equal);
2458
- // A mutation is an imperative command, so `triggerOnSameRequest` means "fire on EVERY mutate(),
2459
- // even with an identical body". By default we dedup an identical value/request while one is in
2460
- // flight (double-click protection); when this is set, both the `next` and `req` dedup are bypassed
2461
- // so a repeat click isn't silently swallowed mid-flight. (Otherwise it'd be dropped until `next`
2462
- // resets to NULL on settle — the "every other click" symptom.)
2463
2508
  const triggerOnSame = options.triggerOnSameRequest ?? false;
2464
2509
  const eq = equal ?? Object.is;
2465
2510
  const next = signal(NULL_VALUE, { ...(ngDevMode ? { debugName: "next" } : /* istanbul ignore next */ {}), equal: (a, b) => {
@@ -2474,26 +2519,14 @@ function mutationResource(request, options0 = {}) {
2474
2519
  const queueEnabled = !!options.queue;
2475
2520
  const queueKeyFn = typeof options.queue === 'object' ? options.queue.key : undefined;
2476
2521
  const queue = linkedSignal({ ...(ngDevMode ? { debugName: "queue" } : /* istanbul ignore next */ {}), source: () => queueKeyFn?.(),
2477
- computation: () => signal([]) });
2478
- let ctx = undefined;
2479
- const queueRef = effect(() => {
2480
- const q = queue(); // subscribe to swaps (key change / clearQueue)
2481
- const nextInQueue = q().at(0); // subscribe to contents
2482
- if (nextInQueue === undefined || next() !== NULL_VALUE)
2483
- return;
2484
- q.update((arr) => arr.slice(1));
2485
- const [value, ictx] = nextInQueue;
2486
- try {
2487
- ctx = onMutate?.(value, ictx);
2488
- next.set(value);
2489
- }
2490
- catch (mutationErr) {
2491
- ctx = undefined;
2492
- next.set(NULL_VALUE);
2493
- if (isDevMode())
2494
- console.error('[@mmstack/resource]: error thrown in onMutate hook, mutation was not applied', mutationErr);
2495
- }
2496
- }, { ...(ngDevMode ? { debugName: "queueRef" } : /* istanbul ignore next */ {}), injector: options.injector });
2522
+ computation: (_key, prev) => {
2523
+ // On a queue key change the previous pending entries are dropped — reject any
2524
+ // mutateAsync promises waiting on them so awaiters don't hang.
2525
+ if (prev)
2526
+ for (const [, , deferred] of untracked(prev.value))
2527
+ deferred?.reject(new MutationCancelledError('queue-key-changed', 'mutation dropped: queue key changed before it ran'));
2528
+ return signal([]);
2529
+ } });
2497
2530
  const req = computed(() => {
2498
2531
  const nr = next();
2499
2532
  if (nr === NULL_VALUE)
@@ -2508,6 +2541,57 @@ function mutationResource(request, options0 = {}) {
2508
2541
  return false;
2509
2542
  return requestEqual(a, b);
2510
2543
  } });
2544
+ let ctx = undefined;
2545
+ let currentDeferred;
2546
+ const begin = (value, ictx, deferred) => {
2547
+ let nextCtx;
2548
+ try {
2549
+ nextCtx = onMutate?.(value, ictx);
2550
+ }
2551
+ catch (mutationErr) {
2552
+ // match legacy mutate(): the throw aborts the mutation and resets state
2553
+ ctx = undefined;
2554
+ next.set(NULL_VALUE);
2555
+ deferred?.reject(mutationErr);
2556
+ if (isDevMode())
2557
+ console.error('[@mmstack/resource]: error thrown in onMutate hook, mutation was not applied', mutationErr);
2558
+ return;
2559
+ }
2560
+ ctx = nextCtx;
2561
+ currentDeferred = deferred;
2562
+ next.set(value);
2563
+ if (deferred && untracked(req) === undefined) {
2564
+ ctx = undefined;
2565
+ currentDeferred = undefined;
2566
+ next.set(NULL_VALUE);
2567
+ deferred.reject(new MutationCancelledError('no-request', 'mutation not sent: request() returned undefined'));
2568
+ }
2569
+ };
2570
+ const supersedeInFlight = () => {
2571
+ if (untracked(next) === NULL_VALUE)
2572
+ return;
2573
+ if (isDevMode())
2574
+ 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.');
2575
+ try {
2576
+ onSettled?.(ctx);
2577
+ }
2578
+ catch (settleErr) {
2579
+ if (isDevMode())
2580
+ console.error('[@mmstack/resource]: error thrown in onSettled hook for a superseded mutation', settleErr);
2581
+ }
2582
+ currentDeferred?.reject(new MutationCancelledError('superseded', 'mutation superseded by a newer mutation (latest-wins)'));
2583
+ currentDeferred = undefined;
2584
+ ctx = undefined;
2585
+ };
2586
+ const queueRef = effect(() => {
2587
+ const q = queue(); // subscribe to swaps (key change / clearQueue)
2588
+ const nextInQueue = q().at(0); // subscribe to contents
2589
+ if (nextInQueue === undefined || next() !== NULL_VALUE)
2590
+ return;
2591
+ q.update((arr) => arr.slice(1));
2592
+ const [value, ictx, deferred] = nextInQueue;
2593
+ begin(value, ictx, deferred);
2594
+ }, { ...(ngDevMode ? { debugName: "queueRef" } : /* istanbul ignore next */ {}), injector: options.injector });
2511
2595
  const lastValue = linkedSignal({ ...(ngDevMode ? { debugName: "lastValue" } : /* istanbul ignore next */ {}), source: next,
2512
2596
  computation: (next, prev) => {
2513
2597
  if (next === NULL_VALUE && !!prev)
@@ -2562,8 +2646,12 @@ function mutationResource(request, options0 = {}) {
2562
2646
  return NULL_VALUE;
2563
2647
  }), filter((v) => v !== NULL_VALUE), takeUntilDestroyed(destroyRef))
2564
2648
  .subscribe((result) => {
2565
- if (result.status === 'error')
2649
+ const deferred = currentDeferred;
2650
+ currentDeferred = undefined;
2651
+ if (result.status === 'error') {
2566
2652
  onError?.(result.error, ctx);
2653
+ deferred?.reject(result.error);
2654
+ }
2567
2655
  else {
2568
2656
  onSuccess?.(result.value, ctx);
2569
2657
  if (cache && invalidates) {
@@ -2571,11 +2659,10 @@ function mutationResource(request, options0 = {}) {
2571
2659
  const prefixes = typeof invalidates === 'function'
2572
2660
  ? invalidates(result.value, (mutation === NULL_VALUE ? undefined : mutation))
2573
2661
  : invalidates;
2574
- // auto-keys are `${method}:${url}:...` — a `GET:`-prefixed url prefix hits
2575
- // the url with any params/subpaths and every varyHeaders variant
2576
2662
  for (const prefix of prefixes)
2577
- cache.invalidatePrefix(`GET:${prefix}`);
2663
+ cache.invalidateUrlPrefix(prefix, invalidateMatcher);
2578
2664
  }
2665
+ deferred?.resolve(result.value);
2579
2666
  }
2580
2667
  onSettled?.(ctx);
2581
2668
  ctx = undefined;
@@ -2587,39 +2674,32 @@ function mutationResource(request, options0 = {}) {
2587
2674
  // queue first — a late queue flush must not poke an already-destroyed resource
2588
2675
  queueRef.destroy();
2589
2676
  statusSub.unsubscribe();
2677
+ // reject any outstanding mutateAsync promises so awaiters don't hang
2678
+ const cancelled = new MutationCancelledError('destroyed', 'mutation abandoned: resource destroyed');
2679
+ currentDeferred?.reject(cancelled);
2680
+ currentDeferred = undefined;
2681
+ for (const [, , deferred] of untracked(queue)())
2682
+ deferred?.reject(cancelled);
2590
2683
  resource.destroy();
2591
2684
  },
2592
2685
  mutate: (value, ictx) => {
2593
2686
  if (queueEnabled) {
2594
- return queue().update((arr) => [...arr, [value, ictx]]);
2687
+ queue().update((arr) => [...arr, [value, ictx, undefined]]);
2688
+ return;
2689
+ }
2690
+ supersedeInFlight();
2691
+ begin(value, ictx, undefined);
2692
+ },
2693
+ mutateAsync: (value, ictx) => {
2694
+ const deferred = Promise.withResolvers();
2695
+ if (queueEnabled) {
2696
+ queue().update((arr) => [...arr, [value, ictx, deferred]]);
2595
2697
  }
2596
2698
  else {
2597
- // latest-wins: a mutation already in flight gets superseded (its request is
2598
- // aborted by the request change), so its onSuccess/onError will never fire —
2599
- // settle its context NOW so optimistic state can be rolled back/cleaned up
2600
- if (untracked(next) !== NULL_VALUE) {
2601
- if (isDevMode())
2602
- 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.');
2603
- try {
2604
- onSettled?.(ctx);
2605
- }
2606
- catch (settleErr) {
2607
- if (isDevMode())
2608
- console.error('[@mmstack/resource]: error thrown in onSettled hook for a superseded mutation', settleErr);
2609
- }
2610
- ctx = undefined;
2611
- }
2612
- try {
2613
- ctx = onMutate?.(value, ictx);
2614
- next.set(value);
2615
- }
2616
- catch (mutationErr) {
2617
- ctx = undefined;
2618
- next.set(NULL_VALUE);
2619
- if (isDevMode())
2620
- console.error('[@mmstack/resource]: error thrown in onMutate hook, mutation was not applied', mutationErr);
2621
- }
2699
+ supersedeInFlight();
2700
+ begin(value, ictx, deferred);
2622
2701
  }
2702
+ return deferred.promise;
2623
2703
  },
2624
2704
  current: computed(() => {
2625
2705
  const nv = next();
@@ -2628,7 +2708,11 @@ function mutationResource(request, options0 = {}) {
2628
2708
  clearQueue: () => {
2629
2709
  if (!queueEnabled)
2630
2710
  return;
2711
+ const dropped = untracked(queue)();
2631
2712
  queue.set(signal([]));
2713
+ // reject mutateAsync promises whose entries we just dropped
2714
+ for (const [, , deferred] of dropped)
2715
+ deferred?.reject(new MutationCancelledError('queue-cleared', 'mutation dropped: queue cleared before it ran'));
2632
2716
  },
2633
2717
  // redeclare disabled with last value so that it is not affected by the resource's internal disablement logic
2634
2718
  disabled: computed(() => cb.isOpen() || lastValueRequest() === undefined),
@@ -2641,5 +2725,5 @@ function mutationResource(request, options0 = {}) {
2641
2725
  * Generated bundle index. Do not edit.
2642
2726
  */
2643
2727
 
2644
- export { Cache, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, hashRequest, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
2728
+ export { Cache, MutationCancelledError, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, hashRequest, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
2645
2729
  //# sourceMappingURL=mmstack-resource.mjs.map