@veams/status-quo-query 0.4.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/README.md +373 -7
- package/dist/index.d.ts +1 -0
- package/dist/mutation.d.ts +28 -0
- package/dist/mutation.js +86 -18
- package/dist/mutation.js.map +1 -1
- package/dist/provider.d.ts +22 -3
- package/dist/provider.js +31 -2
- package/dist/provider.js.map +1 -1
- package/dist/query.d.ts +21 -0
- package/dist/query.js +84 -23
- 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__/tracked.spec.ts +276 -0
- package/src/index.ts +9 -0
- package/src/mutation.ts +196 -22
- package/src/provider.ts +104 -2
- package/src/query.ts +174 -30
- package/src/tracking.ts +384 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { QueryClient } from '@tanstack/query-core';
|
|
2
|
+
|
|
3
|
+
import { setupQueryManager } from '../provider';
|
|
4
|
+
|
|
5
|
+
describe('Tracked Query Invalidation', () => {
|
|
6
|
+
it('registers tracked queries from deps and ignores view data during invalidation', async () => {
|
|
7
|
+
const queryClient = new QueryClient({
|
|
8
|
+
defaultOptions: { mutations: { retry: 0 }, queries: { retry: 0 } },
|
|
9
|
+
});
|
|
10
|
+
const manager = setupQueryManager(queryClient);
|
|
11
|
+
const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries');
|
|
12
|
+
const [createTrackedQuery, createTrackedMutation] = manager.createTrackedQueryAndMutation([
|
|
13
|
+
'applicationId',
|
|
14
|
+
'productId',
|
|
15
|
+
] as const);
|
|
16
|
+
|
|
17
|
+
createTrackedQuery(
|
|
18
|
+
[
|
|
19
|
+
'product',
|
|
20
|
+
{
|
|
21
|
+
deps: { applicationId: 'app-1', productId: 'product-1' },
|
|
22
|
+
view: { page: 2, sort: 'name' },
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
jest.fn().mockResolvedValue('product'),
|
|
26
|
+
{ enabled: false }
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const mutation = createTrackedMutation(jest.fn().mockResolvedValue({ ok: true as const }));
|
|
30
|
+
|
|
31
|
+
await mutation.mutate({
|
|
32
|
+
applicationId: 'app-1',
|
|
33
|
+
productId: 'product-1',
|
|
34
|
+
productName: 'Renamed',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(invalidateQueriesSpy).toHaveBeenCalledWith({
|
|
38
|
+
exact: true,
|
|
39
|
+
queryKey: [
|
|
40
|
+
'product',
|
|
41
|
+
{
|
|
42
|
+
deps: { applicationId: 'app-1', productId: 'product-1' },
|
|
43
|
+
view: { page: 2, sort: 'name' },
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('rejects tracked query keys without a deps object', () => {
|
|
50
|
+
const manager = setupQueryManager(new QueryClient());
|
|
51
|
+
|
|
52
|
+
expect(() =>
|
|
53
|
+
manager.createTrackedQuery(
|
|
54
|
+
['invalid', { view: { page: 1 } }] as never,
|
|
55
|
+
jest.fn().mockResolvedValue('nope')
|
|
56
|
+
)
|
|
57
|
+
).toThrow('Tracked queries require queryKey[queryKey.length - 1].deps to be a plain object.');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('supports partial dependency invalidation by matching only the provided keys', async () => {
|
|
61
|
+
const queryClient = new QueryClient({
|
|
62
|
+
defaultOptions: { mutations: { retry: 0 }, queries: { retry: 0 } },
|
|
63
|
+
});
|
|
64
|
+
const manager = setupQueryManager(queryClient);
|
|
65
|
+
const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries');
|
|
66
|
+
const [createTrackedQuery, createTrackedMutation] = manager.createTrackedQueryAndMutation([
|
|
67
|
+
'applicationId',
|
|
68
|
+
'productId',
|
|
69
|
+
] as const);
|
|
70
|
+
|
|
71
|
+
createTrackedQuery(
|
|
72
|
+
['product', { deps: { applicationId: 'app-1', productId: 'product-1' }, view: { page: 1 } }],
|
|
73
|
+
jest.fn().mockResolvedValue('page-1'),
|
|
74
|
+
{ enabled: false }
|
|
75
|
+
);
|
|
76
|
+
createTrackedQuery(
|
|
77
|
+
['product', { deps: { applicationId: 'app-1', productId: 'product-2' }, view: { page: 2 } }],
|
|
78
|
+
jest.fn().mockResolvedValue('page-2'),
|
|
79
|
+
{ enabled: false }
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const mutation = createTrackedMutation(jest.fn().mockResolvedValue({ ok: true as const }));
|
|
83
|
+
|
|
84
|
+
await mutation.mutate({ applicationId: 'app-1', productName: 'Shared update' });
|
|
85
|
+
|
|
86
|
+
expect(invalidateQueriesSpy).toHaveBeenCalledTimes(2);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('supports union matching and dedupes invalidation calls per query', async () => {
|
|
90
|
+
const queryClient = new QueryClient({
|
|
91
|
+
defaultOptions: { mutations: { retry: 0 }, queries: { retry: 0 } },
|
|
92
|
+
});
|
|
93
|
+
const manager = setupQueryManager(queryClient);
|
|
94
|
+
const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries');
|
|
95
|
+
const [createTrackedQuery, createTrackedMutation] = manager.createTrackedQueryAndMutation([
|
|
96
|
+
'applicationId',
|
|
97
|
+
'productId',
|
|
98
|
+
] as const);
|
|
99
|
+
|
|
100
|
+
createTrackedQuery(
|
|
101
|
+
['product', { deps: { applicationId: 'app-1', productId: 'product-1' }, view: { page: 1 } }],
|
|
102
|
+
jest.fn().mockResolvedValue('product-1'),
|
|
103
|
+
{ enabled: false }
|
|
104
|
+
);
|
|
105
|
+
createTrackedQuery(
|
|
106
|
+
['product', { deps: { applicationId: 'app-1', productId: 'product-2' }, view: { page: 2 } }],
|
|
107
|
+
jest.fn().mockResolvedValue('product-2'),
|
|
108
|
+
{ enabled: false }
|
|
109
|
+
);
|
|
110
|
+
createTrackedQuery(
|
|
111
|
+
['product', { deps: { applicationId: 'app-2', productId: 'product-1' }, view: { page: 3 } }],
|
|
112
|
+
jest.fn().mockResolvedValue('product-3'),
|
|
113
|
+
{ enabled: false }
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const mutation = createTrackedMutation(jest.fn().mockResolvedValue({ ok: true as const }), {
|
|
117
|
+
matchMode: 'union',
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await mutation.mutate({ applicationId: 'app-1', productId: 'product-1' });
|
|
121
|
+
|
|
122
|
+
expect(invalidateQueriesSpy).toHaveBeenCalledTimes(3);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('supports custom dependency resolution for nested mutation variables', async () => {
|
|
126
|
+
const queryClient = new QueryClient({
|
|
127
|
+
defaultOptions: { mutations: { retry: 0 }, queries: { retry: 0 } },
|
|
128
|
+
});
|
|
129
|
+
const manager = setupQueryManager(queryClient);
|
|
130
|
+
const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries');
|
|
131
|
+
|
|
132
|
+
manager.createTrackedQuery(
|
|
133
|
+
['product', { deps: { applicationId: 'app-1', productId: 'product-1' }, view: { page: 1 } }],
|
|
134
|
+
jest.fn().mockResolvedValue('product'),
|
|
135
|
+
{ enabled: false }
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const mutation = manager.createTrackedMutation(
|
|
139
|
+
jest.fn().mockResolvedValue({ ok: true as const }),
|
|
140
|
+
{
|
|
141
|
+
resolveDependencies: (variables: {
|
|
142
|
+
payload: { applicationId: string };
|
|
143
|
+
product: { id: string };
|
|
144
|
+
}) => ({
|
|
145
|
+
applicationId: variables.payload.applicationId,
|
|
146
|
+
productId: variables.product.id,
|
|
147
|
+
}),
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
await mutation.mutate({
|
|
152
|
+
payload: { applicationId: 'app-1' },
|
|
153
|
+
product: { id: 'product-1' },
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(invalidateQueriesSpy).toHaveBeenCalledTimes(1);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('supports error and settled invalidation timing', async () => {
|
|
160
|
+
const queryClient = new QueryClient({
|
|
161
|
+
defaultOptions: { mutations: { retry: 0 }, queries: { retry: 0 } },
|
|
162
|
+
});
|
|
163
|
+
const manager = setupQueryManager(queryClient);
|
|
164
|
+
const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries');
|
|
165
|
+
const [createTrackedQuery, createTrackedMutation] = manager.createTrackedQueryAndMutation([
|
|
166
|
+
'applicationId',
|
|
167
|
+
] as const);
|
|
168
|
+
|
|
169
|
+
createTrackedQuery(
|
|
170
|
+
['product', { deps: { applicationId: 'app-1' }, view: { page: 1 } }],
|
|
171
|
+
jest.fn().mockResolvedValue('product'),
|
|
172
|
+
{ enabled: false }
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const invalidateOnError = createTrackedMutation(jest.fn().mockRejectedValue(new Error('boom')), {
|
|
176
|
+
invalidateOn: 'error',
|
|
177
|
+
});
|
|
178
|
+
const invalidateOnSettled = createTrackedMutation(
|
|
179
|
+
jest.fn().mockRejectedValue(new Error('boom again')),
|
|
180
|
+
{
|
|
181
|
+
invalidateOn: 'settled',
|
|
182
|
+
}
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
await expect(invalidateOnError.mutate({ applicationId: 'app-1' })).rejects.toThrow('boom');
|
|
186
|
+
await expect(invalidateOnSettled.mutate({ applicationId: 'app-1' })).rejects.toThrow(
|
|
187
|
+
'boom again'
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
expect(invalidateQueriesSpy).toHaveBeenCalledTimes(2);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('cleans removed query hashes out of dependency buckets', async () => {
|
|
194
|
+
const queryClient = new QueryClient({
|
|
195
|
+
defaultOptions: { mutations: { retry: 0 }, queries: { retry: 0 } },
|
|
196
|
+
});
|
|
197
|
+
const manager = setupQueryManager(queryClient);
|
|
198
|
+
const cacheGetSpy = jest.spyOn(queryClient.getQueryCache(), 'get');
|
|
199
|
+
const [createTrackedQuery, createTrackedMutation] = manager.createTrackedQueryAndMutation([
|
|
200
|
+
'applicationId',
|
|
201
|
+
] as const);
|
|
202
|
+
|
|
203
|
+
const removedQueryKey = [
|
|
204
|
+
'product',
|
|
205
|
+
{ deps: { applicationId: 'app-1' }, view: { page: 1 } },
|
|
206
|
+
] as const;
|
|
207
|
+
|
|
208
|
+
createTrackedQuery(removedQueryKey, jest.fn().mockResolvedValue('page-1'), {
|
|
209
|
+
enabled: false,
|
|
210
|
+
});
|
|
211
|
+
queryClient.removeQueries({ exact: true, queryKey: removedQueryKey });
|
|
212
|
+
|
|
213
|
+
createTrackedQuery(
|
|
214
|
+
['product', { deps: { applicationId: 'app-1' }, view: { page: 2 } }],
|
|
215
|
+
jest.fn().mockResolvedValue('page-2'),
|
|
216
|
+
{ enabled: false }
|
|
217
|
+
);
|
|
218
|
+
cacheGetSpy.mockClear();
|
|
219
|
+
|
|
220
|
+
const mutation = createTrackedMutation(jest.fn().mockResolvedValue({ ok: true as const }));
|
|
221
|
+
|
|
222
|
+
await mutation.mutate({ applicationId: 'app-1' });
|
|
223
|
+
|
|
224
|
+
expect(cacheGetSpy).toHaveBeenCalledTimes(1);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('re-registers tracked queries on refetch after TanStack cache removal', async () => {
|
|
228
|
+
const queryClient = new QueryClient({
|
|
229
|
+
defaultOptions: { mutations: { retry: 0 }, queries: { retry: 0 } },
|
|
230
|
+
});
|
|
231
|
+
const manager = setupQueryManager(queryClient);
|
|
232
|
+
const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries');
|
|
233
|
+
const [createTrackedQuery, createTrackedMutation] = manager.createTrackedQueryAndMutation([
|
|
234
|
+
'applicationId',
|
|
235
|
+
] as const);
|
|
236
|
+
const queryKey = ['product', { deps: { applicationId: 'app-1' }, view: { page: 1 } }] as const;
|
|
237
|
+
const query = createTrackedQuery(queryKey, jest.fn().mockResolvedValue('product'), {
|
|
238
|
+
enabled: false,
|
|
239
|
+
});
|
|
240
|
+
const mutation = createTrackedMutation(jest.fn().mockResolvedValue({ ok: true as const }));
|
|
241
|
+
|
|
242
|
+
queryClient.removeQueries({ exact: true, queryKey });
|
|
243
|
+
|
|
244
|
+
await mutation.mutate({ applicationId: 'app-1' });
|
|
245
|
+
expect(invalidateQueriesSpy).toHaveBeenCalledTimes(0);
|
|
246
|
+
|
|
247
|
+
await query.refetch();
|
|
248
|
+
await mutation.mutate({ applicationId: 'app-1' });
|
|
249
|
+
|
|
250
|
+
expect(invalidateQueriesSpy).toHaveBeenCalledTimes(1);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('re-registers tracked queries on subscribe after TanStack cache removal', async () => {
|
|
254
|
+
const queryClient = new QueryClient({
|
|
255
|
+
defaultOptions: { mutations: { retry: 0 }, queries: { retry: 0 } },
|
|
256
|
+
});
|
|
257
|
+
const manager = setupQueryManager(queryClient);
|
|
258
|
+
const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries');
|
|
259
|
+
const [createTrackedQuery, createTrackedMutation] = manager.createTrackedQueryAndMutation([
|
|
260
|
+
'applicationId',
|
|
261
|
+
] as const);
|
|
262
|
+
const queryKey = ['product', { deps: { applicationId: 'app-1' }, view: { page: 1 } }] as const;
|
|
263
|
+
const query = createTrackedQuery(queryKey, jest.fn().mockResolvedValue('product'), {
|
|
264
|
+
enabled: false,
|
|
265
|
+
});
|
|
266
|
+
const mutation = createTrackedMutation(jest.fn().mockResolvedValue({ ok: true as const }));
|
|
267
|
+
|
|
268
|
+
queryClient.removeQueries({ exact: true, queryKey });
|
|
269
|
+
|
|
270
|
+
const unsubscribe = query.subscribe(() => undefined);
|
|
271
|
+
await mutation.mutate({ applicationId: 'app-1' });
|
|
272
|
+
unsubscribe();
|
|
273
|
+
|
|
274
|
+
expect(invalidateQueriesSpy).toHaveBeenCalledTimes(1);
|
|
275
|
+
});
|
|
276
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -4,3 +4,12 @@ export * from './mutation';
|
|
|
4
4
|
export * from './query';
|
|
5
5
|
// Re-export all provider-related types and functions for cache management.
|
|
6
6
|
export * from './provider';
|
|
7
|
+
// Re-export tracked dependency types used by the additive tracked facade.
|
|
8
|
+
export type {
|
|
9
|
+
TrackedDependencyRecord,
|
|
10
|
+
TrackedDependencyValue,
|
|
11
|
+
TrackedInvalidateOn,
|
|
12
|
+
TrackedMatchMode,
|
|
13
|
+
TrackedQueryKey,
|
|
14
|
+
TrackedQueryKeySegment,
|
|
15
|
+
} from './tracking';
|
package/src/mutation.ts
CHANGED
|
@@ -15,6 +15,17 @@ import {
|
|
|
15
15
|
type QueryClient,
|
|
16
16
|
} from '@tanstack/query-core';
|
|
17
17
|
|
|
18
|
+
import {
|
|
19
|
+
type TrackedDependencyRecord,
|
|
20
|
+
type TrackingRegistry,
|
|
21
|
+
type TrackedInvalidateOn,
|
|
22
|
+
type TrackedMatchMode,
|
|
23
|
+
type TrackedDependencyValue,
|
|
24
|
+
pickTrackedDependencies,
|
|
25
|
+
resolveTrackedQueries,
|
|
26
|
+
toTrackedDependencyEntries,
|
|
27
|
+
} from './tracking';
|
|
28
|
+
|
|
18
29
|
// Re-export MutationStatus for consistent naming within the service.
|
|
19
30
|
export type MutationStatus = TanstackMutationStatus;
|
|
20
31
|
|
|
@@ -88,6 +99,46 @@ export interface CreateMutation {
|
|
|
88
99
|
): MutationService<TData, TError, TVariables, TOnMutateResult>;
|
|
89
100
|
}
|
|
90
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Additional options for tracked mutations that invalidate queries automatically.
|
|
104
|
+
*
|
|
105
|
+
* The tracked mutation still behaves like a normal mutation service from the outside. These
|
|
106
|
+
* options only describe how the facade should derive dependency values and when it should
|
|
107
|
+
* invalidate matching tracked queries after the mutation lifecycle settles.
|
|
108
|
+
*/
|
|
109
|
+
export interface TrackedMutationServiceOptions<
|
|
110
|
+
TDeps extends TrackedDependencyRecord = TrackedDependencyRecord,
|
|
111
|
+
TData = unknown,
|
|
112
|
+
TError = Error,
|
|
113
|
+
TVariables = void,
|
|
114
|
+
TOnMutateResult = unknown,
|
|
115
|
+
> extends MutationServiceOptions<TData, TError, TVariables, TOnMutateResult> {
|
|
116
|
+
// Optional dependency keys used by the default variable reader.
|
|
117
|
+
dependencyKeys?: readonly (keyof TDeps & string)[];
|
|
118
|
+
// Optional custom resolver when mutation variables do not expose dependency fields directly.
|
|
119
|
+
resolveDependencies?: (variables: TVariables) => Partial<TDeps>;
|
|
120
|
+
// Lifecycle hook that triggers automatic invalidation.
|
|
121
|
+
invalidateOn?: TrackedInvalidateOn;
|
|
122
|
+
// Matching strategy for resolved dependencies.
|
|
123
|
+
matchMode?: TrackedMatchMode;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Function signature for tracked mutation factories.
|
|
128
|
+
*/
|
|
129
|
+
export interface CreateTrackedMutation {
|
|
130
|
+
<
|
|
131
|
+
TDeps extends TrackedDependencyRecord = TrackedDependencyRecord,
|
|
132
|
+
TData = unknown,
|
|
133
|
+
TError = Error,
|
|
134
|
+
TVariables = void,
|
|
135
|
+
TOnMutateResult = unknown,
|
|
136
|
+
>(
|
|
137
|
+
mutationFn: MutationFunction<TData, TVariables>,
|
|
138
|
+
options?: TrackedMutationServiceOptions<TDeps, TData, TError, TVariables, TOnMutateResult>
|
|
139
|
+
): MutationService<TData, TError, TVariables, TOnMutateResult>;
|
|
140
|
+
}
|
|
141
|
+
|
|
91
142
|
/**
|
|
92
143
|
* Prepares the mutation factory by binding it to a specific QueryClient instance.
|
|
93
144
|
*/
|
|
@@ -102,30 +153,98 @@ export function setupMutation(queryClient: QueryClient): CreateMutation {
|
|
|
102
153
|
mutationFn: MutationFunction<TData, TVariables>,
|
|
103
154
|
options?: MutationServiceOptions<TData, TError, TVariables, TOnMutateResult>
|
|
104
155
|
): MutationService<TData, TError, TVariables, TOnMutateResult> {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
156
|
+
return createMutationService(queryClient, mutationFn, options);
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Prepares a tracked mutation factory that coordinates invalidation through the shared registry.
|
|
162
|
+
*
|
|
163
|
+
* The implementation intentionally wraps the normal mutation service instead of re-implementing
|
|
164
|
+
* TanStack lifecycle behavior. TanStack still owns retries, callbacks, and state transitions;
|
|
165
|
+
* the facade only adds dependency resolution plus the follow-up invalidation pass.
|
|
166
|
+
*/
|
|
167
|
+
export function setupTrackedMutation(
|
|
168
|
+
queryClient: QueryClient,
|
|
169
|
+
trackingRegistry: TrackingRegistry,
|
|
170
|
+
defaultDependencyKeys?: readonly string[]
|
|
171
|
+
): CreateTrackedMutation {
|
|
172
|
+
return function createTrackedMutation<
|
|
173
|
+
TDeps extends TrackedDependencyRecord = TrackedDependencyRecord,
|
|
174
|
+
TData = unknown,
|
|
175
|
+
TError = Error,
|
|
176
|
+
TVariables = void,
|
|
177
|
+
TOnMutateResult = unknown,
|
|
178
|
+
>(
|
|
179
|
+
mutationFn: MutationFunction<TData, TVariables>,
|
|
180
|
+
options?: TrackedMutationServiceOptions<TDeps, TData, TError, TVariables, TOnMutateResult>
|
|
181
|
+
): MutationService<TData, TError, TVariables, TOnMutateResult> {
|
|
182
|
+
// Split tracked-only options from the underlying TanStack mutation observer options.
|
|
183
|
+
const {
|
|
184
|
+
dependencyKeys,
|
|
185
|
+
invalidateOn = 'success',
|
|
186
|
+
matchMode = 'intersection',
|
|
187
|
+
resolveDependencies,
|
|
188
|
+
...mutationOptions
|
|
189
|
+
} = options ?? {};
|
|
190
|
+
// Reuse the normal mutation service so snapshots and subscription behavior stay identical.
|
|
191
|
+
const service = createMutationService(queryClient, mutationFn, mutationOptions);
|
|
192
|
+
// The paired helper injects dependency keys here, while standalone tracked mutations can
|
|
193
|
+
// still provide them directly or bypass them with a custom resolver.
|
|
194
|
+
const resolvedDependencyKeys = (dependencyKeys ?? defaultDependencyKeys);
|
|
195
|
+
|
|
196
|
+
const invalidateTrackedQueries = async (variables: TVariables) => {
|
|
197
|
+
// Resolve the mutation variables into the same named dependency shape that tracked queries
|
|
198
|
+
// registered under when they were created.
|
|
199
|
+
const dependencies = resolveTrackedMutationDependencies(
|
|
200
|
+
variables,
|
|
201
|
+
resolvedDependencyKeys,
|
|
202
|
+
resolveDependencies
|
|
203
|
+
);
|
|
204
|
+
// Ask the registry for matching query hashes using the selected invalidation breadth.
|
|
205
|
+
const queryHashes = trackingRegistry.match(
|
|
206
|
+
toTrackedDependencyEntries(dependencies, 'Tracked mutation dependency resolution'),
|
|
207
|
+
matchMode
|
|
208
|
+
);
|
|
209
|
+
// Filter the registry result down to currently live TanStack queries before invalidating.
|
|
210
|
+
const queries = resolveTrackedQueries(queryClient, queryHashes);
|
|
211
|
+
|
|
212
|
+
await Promise.all(
|
|
213
|
+
queries.map((query) =>
|
|
214
|
+
queryClient.invalidateQueries({
|
|
215
|
+
exact: true,
|
|
216
|
+
queryKey: query.queryKey,
|
|
217
|
+
})
|
|
218
|
+
)
|
|
219
|
+
);
|
|
220
|
+
};
|
|
113
221
|
|
|
114
|
-
// Return the implementation of the MutationService interface.
|
|
115
222
|
return {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
223
|
+
...service,
|
|
224
|
+
mutate: async (variables, mutateOptions) => {
|
|
225
|
+
try {
|
|
226
|
+
// Let TanStack finish the mutation first so its own callbacks and state machine remain
|
|
227
|
+
// authoritative. The facade only coordinates the follow-up invalidation.
|
|
228
|
+
const result = await service.mutate(variables, mutateOptions);
|
|
229
|
+
|
|
230
|
+
if (invalidateOn === 'success' || invalidateOn === 'settled') {
|
|
231
|
+
await invalidateTrackedQueries(variables);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return result;
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (invalidateOn === 'error' || invalidateOn === 'settled') {
|
|
237
|
+
try {
|
|
238
|
+
await invalidateTrackedQueries(variables);
|
|
239
|
+
} catch {
|
|
240
|
+
// Preserve the original mutation failure as the primary rejection. If invalidation
|
|
241
|
+
// also fails here, the caller still sees the mutation error that triggered this path.
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
},
|
|
129
248
|
};
|
|
130
249
|
};
|
|
131
250
|
}
|
|
@@ -148,3 +267,58 @@ function toMutationServiceSnapshot<TData, TError, TVariables, TOnMutateResult>(
|
|
|
148
267
|
isSuccess: result.isSuccess,
|
|
149
268
|
};
|
|
150
269
|
}
|
|
270
|
+
|
|
271
|
+
function createMutationService<
|
|
272
|
+
TData = unknown,
|
|
273
|
+
TError = Error,
|
|
274
|
+
TVariables = void,
|
|
275
|
+
TOnMutateResult = unknown,
|
|
276
|
+
>(
|
|
277
|
+
queryClient: QueryClient,
|
|
278
|
+
mutationFn: MutationFunction<TData, TVariables>,
|
|
279
|
+
options?: MutationServiceOptions<TData, TError, TVariables, TOnMutateResult>
|
|
280
|
+
): MutationService<TData, TError, TVariables, TOnMutateResult> {
|
|
281
|
+
// Keep the original mutation implementation in one place so tracked and untracked mutations
|
|
282
|
+
// always expose the same observer-backed runtime behavior.
|
|
283
|
+
const observer = new MutationObserver<TData, TError, TVariables, TOnMutateResult>(queryClient, {
|
|
284
|
+
...options,
|
|
285
|
+
mutationFn,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
getSnapshot: () => toMutationServiceSnapshot(observer.getCurrentResult()),
|
|
290
|
+
subscribe: (listener) =>
|
|
291
|
+
observer.subscribe((result) => {
|
|
292
|
+
listener(toMutationServiceSnapshot(result));
|
|
293
|
+
}),
|
|
294
|
+
mutate: (variables, mutateOptions) => observer.mutate(variables, mutateOptions),
|
|
295
|
+
reset: () => observer.reset(),
|
|
296
|
+
unsafe_getResult: () => observer.getCurrentResult(),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function resolveTrackedMutationDependencies<
|
|
301
|
+
TDeps extends TrackedDependencyRecord,
|
|
302
|
+
TVariables,
|
|
303
|
+
>(
|
|
304
|
+
variables: TVariables,
|
|
305
|
+
dependencyKeys: readonly (keyof TDeps & string)[] | undefined,
|
|
306
|
+
resolveDependencies:
|
|
307
|
+
| ((variables: TVariables) => Partial<TDeps>)
|
|
308
|
+
| undefined
|
|
309
|
+
): Partial<Record<string, TrackedDependencyValue>> {
|
|
310
|
+
// A custom resolver takes precedence because it can adapt nested payloads or renamed fields.
|
|
311
|
+
if (resolveDependencies) {
|
|
312
|
+
return resolveDependencies(variables);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Without a custom resolver, tracked mutations need known dependency keys so the default
|
|
316
|
+
// variable picker knows which fields are invalidation-relevant.
|
|
317
|
+
if (!dependencyKeys || dependencyKeys.length === 0) {
|
|
318
|
+
throw new Error(
|
|
319
|
+
'Tracked mutations require resolveDependencies or dependencyKeys to derive invalidation targets.'
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return pickTrackedDependencies(dependencyKeys, variables);
|
|
324
|
+
}
|
package/src/provider.ts
CHANGED
|
@@ -1,11 +1,54 @@
|
|
|
1
1
|
import {
|
|
2
2
|
// Import the central QueryClient to handle management and state management.
|
|
3
3
|
type QueryClient,
|
|
4
|
+
type MutationFunction,
|
|
4
5
|
} from '@tanstack/query-core';
|
|
5
6
|
|
|
6
7
|
// Import mutation and query setup functions and their factory types.
|
|
7
|
-
import {
|
|
8
|
-
|
|
8
|
+
import {
|
|
9
|
+
type CreateMutation,
|
|
10
|
+
type CreateTrackedMutation,
|
|
11
|
+
type MutationService,
|
|
12
|
+
type TrackedMutationServiceOptions,
|
|
13
|
+
setupMutation,
|
|
14
|
+
setupTrackedMutation,
|
|
15
|
+
} from './mutation';
|
|
16
|
+
import { type CreateQuery, type CreateTrackedQuery, setupQuery, setupTrackedQuery } from './query';
|
|
17
|
+
import {
|
|
18
|
+
createTrackingRegistry,
|
|
19
|
+
type TrackedDependencyValue,
|
|
20
|
+
} from './tracking';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Mutation factory returned by the paired tracked helper.
|
|
24
|
+
*
|
|
25
|
+
* This omits `dependencyKeys` on purpose because the paired helper already captured those keys
|
|
26
|
+
* once at setup time and injects them automatically for each tracked mutation it creates.
|
|
27
|
+
*/
|
|
28
|
+
export interface CreateTrackedMutationWithDefaults<TDependencyKey extends string> {
|
|
29
|
+
<TData = unknown, TError = Error, TVariables = void, TOnMutateResult = unknown>(
|
|
30
|
+
mutationFn: MutationFunction<TData, TVariables>,
|
|
31
|
+
options?: Omit<
|
|
32
|
+
TrackedMutationServiceOptions<
|
|
33
|
+
Record<TDependencyKey, TrackedDependencyValue>,
|
|
34
|
+
TData,
|
|
35
|
+
TError,
|
|
36
|
+
TVariables,
|
|
37
|
+
TOnMutateResult
|
|
38
|
+
>,
|
|
39
|
+
'dependencyKeys'
|
|
40
|
+
>
|
|
41
|
+
): MutationService<TData, TError, TVariables, TOnMutateResult>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Paired tracked helper that captures dependency keys once for default mutation resolution.
|
|
46
|
+
*/
|
|
47
|
+
export interface CreateTrackedQueryAndMutation {
|
|
48
|
+
<const TDependencyKeys extends readonly string[]>(
|
|
49
|
+
dependencyKeys: TDependencyKeys
|
|
50
|
+
): readonly [CreateTrackedQuery, CreateTrackedMutationWithDefaults<TDependencyKeys[number]>];
|
|
51
|
+
}
|
|
9
52
|
|
|
10
53
|
/**
|
|
11
54
|
* Defines the public API for the query manager facade.
|
|
@@ -15,6 +58,12 @@ export interface QueryManager {
|
|
|
15
58
|
createMutation: CreateMutation;
|
|
16
59
|
// Factory for creating a query service within the context of this provider.
|
|
17
60
|
createQuery: CreateQuery;
|
|
61
|
+
// Factory for creating tracked query services with dependency-aware query keys.
|
|
62
|
+
createTrackedQuery: CreateTrackedQuery;
|
|
63
|
+
// Factory for creating tracked mutation services with automatic invalidation.
|
|
64
|
+
createTrackedMutation: CreateTrackedMutation;
|
|
65
|
+
// Convenience helper that shares dependency keys between tracked queries and mutations.
|
|
66
|
+
createTrackedQueryAndMutation: CreateTrackedQueryAndMutation;
|
|
18
67
|
// Cancels active queries for the specified filters.
|
|
19
68
|
cancelQueries: QueryClient['cancelQueries'];
|
|
20
69
|
// Synchronously retrieves a snapshot of the current query data.
|
|
@@ -37,12 +86,65 @@ export interface QueryManager {
|
|
|
37
86
|
* Prepares the query manager facade by binding all actions to a specific QueryClient instance.
|
|
38
87
|
*/
|
|
39
88
|
export function setupQueryManager(queryClient: QueryClient): QueryManager {
|
|
89
|
+
// One shared registry is the whole point of the manager-only tracked API. Queries and
|
|
90
|
+
// mutations created from the same manager can now coordinate invalidation through it.
|
|
91
|
+
const trackingRegistry = createTrackingRegistry();
|
|
92
|
+
const trackedQueryFactory = setupTrackedQuery(queryClient, trackingRegistry);
|
|
93
|
+
const trackedMutationFactory = setupTrackedMutation(queryClient, trackingRegistry);
|
|
94
|
+
|
|
95
|
+
queryClient.getQueryCache().subscribe((event) => {
|
|
96
|
+
if (event.type === 'removed') {
|
|
97
|
+
// When TanStack GC removes a query from the live cache, drop its hash from our registry too.
|
|
98
|
+
// A later tracked `refetch()` or first `subscribe()` will re-register it if it becomes live
|
|
99
|
+
// again. This keeps the registry aligned with TanStack's actual cache lifetime.
|
|
100
|
+
trackingRegistry.unregister(event.query.queryHash);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
40
104
|
// Return the implementation of the QueryManager interface.
|
|
41
105
|
return {
|
|
42
106
|
// Bind mutation factory to this QueryClient.
|
|
43
107
|
createMutation: setupMutation(queryClient),
|
|
44
108
|
// Bind query factory to this QueryClient.
|
|
45
109
|
createQuery: setupQuery(queryClient),
|
|
110
|
+
// Bind tracked query factory to this client and registry.
|
|
111
|
+
createTrackedQuery: trackedQueryFactory,
|
|
112
|
+
// Bind tracked mutation factory to this client and registry.
|
|
113
|
+
createTrackedMutation: trackedMutationFactory,
|
|
114
|
+
// Provide a paired helper that captures dependency keys once.
|
|
115
|
+
createTrackedQueryAndMutation: <const TDependencyKeys extends readonly string[]>(
|
|
116
|
+
dependencyKeys: TDependencyKeys
|
|
117
|
+
) => {
|
|
118
|
+
const createTrackedMutationWithDefaults: CreateTrackedMutationWithDefaults<
|
|
119
|
+
TDependencyKeys[number]
|
|
120
|
+
> = <TData = unknown, TError = Error, TVariables = void, TOnMutateResult = unknown>(
|
|
121
|
+
mutationFn: MutationFunction<TData, TVariables>,
|
|
122
|
+
options?: Omit<
|
|
123
|
+
TrackedMutationServiceOptions<
|
|
124
|
+
Record<TDependencyKeys[number], TrackedDependencyValue>,
|
|
125
|
+
TData,
|
|
126
|
+
TError,
|
|
127
|
+
TVariables,
|
|
128
|
+
TOnMutateResult
|
|
129
|
+
>,
|
|
130
|
+
'dependencyKeys'
|
|
131
|
+
>
|
|
132
|
+
) =>
|
|
133
|
+
// Inject the dependency keys once so the paired mutation factory can derive dependency
|
|
134
|
+
// values directly from mutation variables without repeating the mapping each time.
|
|
135
|
+
trackedMutationFactory<
|
|
136
|
+
Record<TDependencyKeys[number], TrackedDependencyValue>,
|
|
137
|
+
TData,
|
|
138
|
+
TError,
|
|
139
|
+
TVariables,
|
|
140
|
+
TOnMutateResult
|
|
141
|
+
>(mutationFn, {
|
|
142
|
+
...options,
|
|
143
|
+
dependencyKeys,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return [trackedQueryFactory, createTrackedMutationWithDefaults] as const;
|
|
147
|
+
},
|
|
46
148
|
// Proxy for canceling queries with this client context.
|
|
47
149
|
cancelQueries: queryClient.cancelQueries.bind(queryClient),
|
|
48
150
|
// Proxy for retrieving query data with this client context.
|