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