@veams/status-quo-query 0.3.0 → 0.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/.turbo/turbo-build.log +14 -12
- package/README.md +382 -16
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/mutation.d.ts +43 -0
- package/dist/mutation.js +97 -12
- package/dist/mutation.js.map +1 -1
- package/dist/provider.d.ts +30 -5
- package/dist/provider.js +47 -3
- package/dist/provider.js.map +1 -1
- package/dist/query.d.ts +48 -0
- package/dist/query.js +109 -17
- package/dist/query.js.map +1 -1
- package/dist/tracking.d.ts +85 -0
- package/dist/tracking.js +226 -0
- package/dist/tracking.js.map +1 -0
- package/package.json +15 -1
- package/src/__tests__/mutation.spec.ts +2 -2
- package/src/__tests__/provider.spec.ts +12 -12
- package/src/__tests__/tracked.spec.ts +276 -0
- package/src/index.ts +12 -0
- package/src/mutation.ts +239 -15
- package/src/provider.ts +135 -4
- package/src/query.ts +242 -23
- package/src/tracking.ts +384 -0
package/src/tracking.ts
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type DefaultError,
|
|
3
|
+
type Query,
|
|
4
|
+
type QueryClient,
|
|
5
|
+
type QueryKey,
|
|
6
|
+
} from '@tanstack/query-core';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Restrict tracked dependency values to stable primitive cache tokens.
|
|
10
|
+
*/
|
|
11
|
+
export type TrackedDependencyValue = string | number | boolean | null | undefined;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Named dependency map used by tracked queries and mutations.
|
|
15
|
+
*/
|
|
16
|
+
export type TrackedDependencyRecord = Record<string, TrackedDependencyValue>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Supported tracked invalidation match strategies.
|
|
20
|
+
*/
|
|
21
|
+
export type TrackedMatchMode = 'intersection' | 'union';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Supported tracked invalidation timing hooks.
|
|
25
|
+
*/
|
|
26
|
+
export type TrackedInvalidateOn = 'success' | 'error' | 'settled';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Final tracked key segment that separates dependency and view semantics.
|
|
30
|
+
*/
|
|
31
|
+
export type TrackedQueryKeySegment<TDeps extends TrackedDependencyRecord = TrackedDependencyRecord> =
|
|
32
|
+
{
|
|
33
|
+
deps: TDeps;
|
|
34
|
+
view?: Record<string, unknown>;
|
|
35
|
+
} & Record<string, unknown>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Tracked query keys must end with a dependency-aware object segment.
|
|
39
|
+
*/
|
|
40
|
+
export type TrackedQueryKey<TDeps extends TrackedDependencyRecord = TrackedDependencyRecord> =
|
|
41
|
+
readonly [...readonly unknown[], TrackedQueryKeySegment<TDeps>];
|
|
42
|
+
|
|
43
|
+
export interface TrackedDependencyEntry {
|
|
44
|
+
// Pre-serialized dependency value used as the map key inside the registry.
|
|
45
|
+
key: string;
|
|
46
|
+
// The human-readable dependency name, for example `applicationId`.
|
|
47
|
+
name: string;
|
|
48
|
+
// The original primitive value preserved for debugging and error messages.
|
|
49
|
+
value: TrackedDependencyValue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface TrackingRegistry {
|
|
53
|
+
// Quick reverse-index lookup used by tracked queries during re-registration.
|
|
54
|
+
has: (queryHash: string) => boolean;
|
|
55
|
+
// Resolves query hashes by dependency pairs and the chosen match strategy.
|
|
56
|
+
match: (dependencies: readonly TrackedDependencyEntry[], mode: TrackedMatchMode) => Set<string>;
|
|
57
|
+
// Adds or replaces all dependency registrations for a query hash.
|
|
58
|
+
register: (queryHash: string, dependencies: readonly TrackedDependencyEntry[]) => void;
|
|
59
|
+
// Removes a query hash from every dependency bucket it was registered in.
|
|
60
|
+
unregister: (queryHash: string) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Builds the internal registry that connects named dependency values to TanStack query hashes.
|
|
65
|
+
*
|
|
66
|
+
* We intentionally keep two indexes:
|
|
67
|
+
* - byDependencyName: fast forward lookup for invalidation
|
|
68
|
+
* - byQueryHash: fast reverse lookup for cleanup when TanStack removes a query
|
|
69
|
+
*
|
|
70
|
+
* The reverse index is what lets us react to TanStack `removed` events without scanning the
|
|
71
|
+
* entire registry and without leaking dead query hashes over time.
|
|
72
|
+
*/
|
|
73
|
+
export function createTrackingRegistry(): TrackingRegistry {
|
|
74
|
+
// Forward index: dependency name -> serialized dependency value -> query hashes.
|
|
75
|
+
const byDependencyName = new Map<string, Map<string, Set<string>>>();
|
|
76
|
+
// Reverse index: query hash -> dependency entries originally registered for that query.
|
|
77
|
+
const byQueryHash = new Map<string, readonly TrackedDependencyEntry[]>();
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
has: (queryHash) => byQueryHash.has(queryHash),
|
|
81
|
+
match: (dependencies, mode) => matchTrackedQueryHashes(byDependencyName, dependencies, mode),
|
|
82
|
+
register: (queryHash, dependencies) => {
|
|
83
|
+
// Re-registration should replace stale mappings instead of accumulating duplicates.
|
|
84
|
+
if (byQueryHash.has(queryHash)) {
|
|
85
|
+
unregisterTrackedQueryHash(byDependencyName, byQueryHash, queryHash);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
byQueryHash.set(queryHash, dependencies);
|
|
89
|
+
|
|
90
|
+
for (const dependency of dependencies) {
|
|
91
|
+
// One query can be addressed through several dependency dimensions at once.
|
|
92
|
+
const valueMap = getOrCreateMap(byDependencyName, dependency.name);
|
|
93
|
+
const queryHashes = getOrCreateSet(valueMap, dependency.key);
|
|
94
|
+
queryHashes.add(queryHash);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
unregister: (queryHash) => {
|
|
98
|
+
unregisterTrackedQueryHash(byDependencyName, byQueryHash, queryHash);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Extracts tracked dependency entries from the final query-key segment.
|
|
105
|
+
*
|
|
106
|
+
* The tracked contract is intentionally strict:
|
|
107
|
+
* - the last query-key segment must be an object
|
|
108
|
+
* - `deps` must exist on that object
|
|
109
|
+
* - only `deps` participates in tracking
|
|
110
|
+
*
|
|
111
|
+
* Everything else in the key, including `view`, is still part of the TanStack cache key,
|
|
112
|
+
* but is ignored for invalidation matching.
|
|
113
|
+
*/
|
|
114
|
+
export function extractTrackedDependencies(queryKey: QueryKey): readonly TrackedDependencyEntry[] {
|
|
115
|
+
const finalSegment = queryKey.at(-1);
|
|
116
|
+
|
|
117
|
+
if (!isPlainObject(finalSegment)) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
'Tracked queries require the final queryKey segment to be an object with a deps property.'
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const dependencies = finalSegment.deps;
|
|
124
|
+
|
|
125
|
+
if (!isPlainObject(dependencies)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
'Tracked queries require queryKey[queryKey.length - 1].deps to be a plain object.'
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return toTrackedDependencyEntries(dependencies, 'Tracked query dependencies');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Converts a dependency record into normalized registry entries.
|
|
136
|
+
*
|
|
137
|
+
* Normalization happens here so the registry only ever works with one internal shape,
|
|
138
|
+
* regardless of whether the dependencies came from a query key or a mutation resolver.
|
|
139
|
+
*/
|
|
140
|
+
export function toTrackedDependencyEntries(
|
|
141
|
+
dependencies: Record<string, unknown>,
|
|
142
|
+
contextLabel: string
|
|
143
|
+
): readonly TrackedDependencyEntry[] {
|
|
144
|
+
return Object.entries(dependencies).map(([name, value]) => {
|
|
145
|
+
assertTrackedDependencyValue(name, value, contextLabel);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
key: serializeTrackedDependencyValue(value),
|
|
149
|
+
name,
|
|
150
|
+
value,
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Default dependency resolver used by the paired helper.
|
|
157
|
+
*
|
|
158
|
+
* This is intentionally permissive with partial results. If a mutation variable object only
|
|
159
|
+
* exposes some dependency keys, we track only those keys and let the chosen match mode decide
|
|
160
|
+
* how broad the invalidation should be.
|
|
161
|
+
*/
|
|
162
|
+
export function pickTrackedDependencies<TVariables>(
|
|
163
|
+
dependencyKeys: readonly string[],
|
|
164
|
+
variables: TVariables
|
|
165
|
+
): Partial<Record<string, TrackedDependencyValue>> {
|
|
166
|
+
if (!isRecordLike(variables)) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
'Tracked mutations need object-like variables when using dependencyKeys without resolveDependencies.'
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const resolved: Partial<Record<string, TrackedDependencyValue>> = {};
|
|
173
|
+
|
|
174
|
+
for (const dependencyKey of dependencyKeys) {
|
|
175
|
+
if (!(dependencyKey in variables)) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const value = variables[dependencyKey];
|
|
180
|
+
assertTrackedDependencyValue(
|
|
181
|
+
dependencyKey,
|
|
182
|
+
value,
|
|
183
|
+
'Tracked mutation dependency resolution'
|
|
184
|
+
);
|
|
185
|
+
resolved[dependencyKey] = value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return resolved;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Resolves live TanStack queries from tracked query hashes.
|
|
193
|
+
*
|
|
194
|
+
* The registry may contain hashes that were valid when the invalidation pass started but have
|
|
195
|
+
* already disappeared from TanStack's cache. Filtering to live queries here keeps invalidation
|
|
196
|
+
* exact and avoids relying on stale registry state.
|
|
197
|
+
*/
|
|
198
|
+
export function resolveTrackedQueries(
|
|
199
|
+
queryClient: QueryClient,
|
|
200
|
+
queryHashes: Iterable<string>
|
|
201
|
+
): Array<Query<unknown, DefaultError, unknown, QueryKey>> {
|
|
202
|
+
const queries: Array<Query<unknown, DefaultError, unknown, QueryKey>> = [];
|
|
203
|
+
|
|
204
|
+
for (const queryHash of queryHashes) {
|
|
205
|
+
const query = queryClient.getQueryCache().get(queryHash);
|
|
206
|
+
|
|
207
|
+
if (query) {
|
|
208
|
+
queries.push(query);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return queries;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function assertTrackedDependencyValue(
|
|
216
|
+
name: string,
|
|
217
|
+
value: unknown,
|
|
218
|
+
contextLabel: string
|
|
219
|
+
): asserts value is TrackedDependencyValue {
|
|
220
|
+
if (
|
|
221
|
+
value === null ||
|
|
222
|
+
value === undefined ||
|
|
223
|
+
typeof value === 'string' ||
|
|
224
|
+
typeof value === 'number' ||
|
|
225
|
+
typeof value === 'boolean'
|
|
226
|
+
) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
throw new Error(
|
|
231
|
+
`${contextLabel} only support primitive dependency values. "${name}" received ${typeof value}.`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function getOrCreateMap<TKey, TValue>(
|
|
236
|
+
map: Map<TKey, Map<string, TValue>>,
|
|
237
|
+
key: TKey
|
|
238
|
+
): Map<string, TValue> {
|
|
239
|
+
const existing = map.get(key);
|
|
240
|
+
|
|
241
|
+
if (existing) {
|
|
242
|
+
return existing;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const created = new Map<string, TValue>();
|
|
246
|
+
map.set(key, created);
|
|
247
|
+
return created;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function getOrCreateSet<TKey>(map: Map<TKey, Set<string>>, key: TKey): Set<string> {
|
|
251
|
+
const existing = map.get(key);
|
|
252
|
+
|
|
253
|
+
if (existing) {
|
|
254
|
+
return existing;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const created = new Set<string>();
|
|
258
|
+
map.set(key, created);
|
|
259
|
+
return created;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
263
|
+
if (!isRecordLike(value)) {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
Object.getPrototypeOf(value as object) === Object.prototype ||
|
|
269
|
+
Object.getPrototypeOf(value as object) === null
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isRecordLike(value: unknown): value is Record<string, unknown> {
|
|
274
|
+
return typeof value === 'object' && value !== null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function matchTrackedQueryHashes(
|
|
278
|
+
byDependencyName: Map<string, Map<string, Set<string>>>,
|
|
279
|
+
dependencies: readonly TrackedDependencyEntry[],
|
|
280
|
+
mode: TrackedMatchMode
|
|
281
|
+
): Set<string> {
|
|
282
|
+
// No dependency input means no automatic invalidation target.
|
|
283
|
+
if (dependencies.length === 0) {
|
|
284
|
+
return new Set<string>();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (mode === 'union') {
|
|
288
|
+
// Union broadens invalidation to any query that matches at least one dependency pair.
|
|
289
|
+
const matches = new Set<string>();
|
|
290
|
+
|
|
291
|
+
for (const dependency of dependencies) {
|
|
292
|
+
for (const queryHash of getTrackedQueryHashes(byDependencyName, dependency)) {
|
|
293
|
+
matches.add(queryHash);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return matches;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Intersection narrows invalidation to queries that match every provided dependency pair.
|
|
301
|
+
let matches: Set<string> | undefined;
|
|
302
|
+
|
|
303
|
+
for (const dependency of dependencies) {
|
|
304
|
+
const queryHashes = getTrackedQueryHashes(byDependencyName, dependency);
|
|
305
|
+
|
|
306
|
+
if (queryHashes.size === 0) {
|
|
307
|
+
return new Set<string>();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!matches) {
|
|
311
|
+
matches = new Set(queryHashes);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
for (const queryHash of [...matches]) {
|
|
316
|
+
if (!queryHashes.has(queryHash)) {
|
|
317
|
+
matches.delete(queryHash);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return matches ?? new Set<string>();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function getTrackedQueryHashes(
|
|
326
|
+
byDependencyName: Map<string, Map<string, Set<string>>>,
|
|
327
|
+
dependency: TrackedDependencyEntry
|
|
328
|
+
): ReadonlySet<string> {
|
|
329
|
+
return byDependencyName.get(dependency.name)?.get(dependency.key) ?? new Set<string>();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function serializeTrackedDependencyValue(value: TrackedDependencyValue): string {
|
|
333
|
+
// The registry stores values as strings, so include the primitive type to avoid collisions
|
|
334
|
+
// such as `"1"` and `1` landing in the same bucket.
|
|
335
|
+
if (value === null) {
|
|
336
|
+
return 'null';
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (value === undefined) {
|
|
340
|
+
return 'undefined';
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return `${typeof value}:${String(value)}`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function unregisterTrackedQueryHash(
|
|
347
|
+
byDependencyName: Map<string, Map<string, Set<string>>>,
|
|
348
|
+
byQueryHash: Map<string, readonly TrackedDependencyEntry[]>,
|
|
349
|
+
queryHash: string
|
|
350
|
+
): void {
|
|
351
|
+
// If the query hash was never tracked or was already cleaned up, there is nothing to do.
|
|
352
|
+
const dependencies = byQueryHash.get(queryHash);
|
|
353
|
+
|
|
354
|
+
if (!dependencies) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
for (const dependency of dependencies) {
|
|
359
|
+
const valueMap = byDependencyName.get(dependency.name);
|
|
360
|
+
|
|
361
|
+
if (!valueMap) {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const queryHashes = valueMap.get(dependency.key);
|
|
366
|
+
|
|
367
|
+
if (!queryHashes) {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
queryHashes.delete(queryHash);
|
|
372
|
+
|
|
373
|
+
// Prune empty buckets so the registry footprint follows the live TanStack cache.
|
|
374
|
+
if (queryHashes.size === 0) {
|
|
375
|
+
valueMap.delete(dependency.key);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (valueMap.size === 0) {
|
|
379
|
+
byDependencyName.delete(dependency.name);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
byQueryHash.delete(queryHash);
|
|
384
|
+
}
|