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