@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.
@@ -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
- // Create a new MutationObserver instance to manage this specific mutation's lifecycle.
106
- const observer = new MutationObserver<TData, TError, TVariables, TOnMutateResult>(
107
- queryClient,
108
- {
109
- ...options,
110
- mutationFn,
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
- // Map the current observer state to our service's snapshot format.
117
- getSnapshot: () => toMutationServiceSnapshot(observer.getCurrentResult()),
118
- // Subscribe to observer changes and notify the listener with updated snapshots.
119
- subscribe: (listener) =>
120
- observer.subscribe((result) => {
121
- listener(toMutationServiceSnapshot(result));
122
- }),
123
- // Proxy the mutate call to the underlying observer.
124
- mutate: (variables, mutateOptions) => observer.mutate(variables, mutateOptions),
125
- // Reset the underlying observer state.
126
- reset: () => observer.reset(),
127
- // Provide direct access to the raw observer result when needed.
128
- unsafe_getResult: () => observer.getCurrentResult(),
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 { type CreateMutation, setupMutation } from './mutation';
8
- import { type CreateQuery, setupQuery } from './query';
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.