@wisemen/vue-core-api-utils 1.2.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,14 @@
1
1
  ---
2
2
  name: getting-started
3
3
  description: >
4
- Install @wisemen/vue-core-api-utils, initialize apiUtilsPlugin with QueryClient config, define typed query keys interface, create API composables with error codes.
4
+ Install @wisemen/vue-core-api-utils, initialize apiUtilsPlugin with QueryClient config, register typed query keys via module augmentation, set up a typed QueryClient wrapper.
5
5
  type: lifecycle
6
6
  library: vue-core-api-utils
7
- library_version: "1.0.1"
8
- sources:
9
- - "wisemen-digital/wisemen-core:docs/packages/api-utils/pages/getting-started/installation.md"
10
- - "wisemen-digital/wisemen-core:packages/web/api-utils/src/plugin/apiUtilsPlugin.ts"
11
- - "wisemen-digital/wisemen-core:packages/web/api-utils/src/config/config.ts"
12
7
  ---
13
8
 
14
9
  # @wisemen/vue-core-api-utils — Getting Started
15
10
 
16
- Get `@wisemen/vue-core-api-utils` installed, your Vue Query plugin initialized, query keys defined, and typed composables created.
11
+ Get `@wisemen/vue-core-api-utils` installed, your Vue Query plugin initialized, query keys registered, and a typed QueryClient created.
17
12
 
18
13
  ## Setup
19
14
 
@@ -23,35 +18,37 @@ Get `@wisemen/vue-core-api-utils` installed, your Vue Query plugin initialized,
23
18
  pnpm install @wisemen/vue-core-api-utils @tanstack/vue-query neverthrow vue
24
19
  ```
25
20
 
26
- ### 2. Define your query keys
21
+ ### 2. Register your query keys via module augmentation
27
22
 
28
- Create a TypeScript interface that maps query keys to their response types and parameters:
23
+ Augment the `Register` interface to define your query keys and error codes:
29
24
 
30
25
  ```typescript
31
26
  // src/types/queryKey.type.ts
32
27
 
33
- export interface ProjectQueryKeys {
34
- // Single entity query
35
- contactDetail: {
36
- entity: Contact
37
- params: { contactUuid: string }
38
- }
39
-
40
- // List query with offset pagination
41
- contactList: {
42
- entity: Contact[]
43
- params: { page: number; limit: number; search?: string }
44
- }
45
-
46
- // List query with keyset pagination
47
- contactListKeyset: {
48
- entity: Contact[]
49
- params: { limit: number; key?: string }
28
+ import type { Contact } from '@/models'
29
+
30
+ declare module '@wisemen/vue-core-api-utils' {
31
+ interface Register {
32
+ queryKeys: {
33
+ contactDetail: {
34
+ entity: Contact
35
+ params: { contactUuid: string }
36
+ }
37
+ contactList: {
38
+ entity: Contact[]
39
+ params: { search?: string }
40
+ }
41
+ contactListKeyset: {
42
+ entity: Contact[]
43
+ params: { search?: string }
44
+ }
45
+ }
46
+ errorCodes: 'NOT_FOUND' | 'UNAUTHORIZED' | 'VALIDATION_ERROR'
50
47
  }
51
48
  }
52
49
  ```
53
50
 
54
- Every key must have both `entity` (response type) and `params` (required parameters).
51
+ Every key must have `entity` (response type) and `params` (filter/identifier parameters). Do not include pagination fields in params — those are handled by the infinite query composables.
55
52
 
56
53
  ### 3. Initialize the plugin in your main.ts
57
54
 
@@ -76,9 +73,9 @@ app.use(apiUtilsPlugin({
76
73
  app.mount('#app')
77
74
  ```
78
75
 
79
- The `apiUtilsPlugin` function creates a QueryClient with your config and handles @tanstack/vue-query setup internally.
76
+ The `apiUtilsPlugin` creates a QueryClient with your config and handles @tanstack/vue-query setup internally.
80
77
 
81
- ### 4. Create your API composables
78
+ ### 4. Create your API module
82
79
 
83
80
  ```typescript
84
81
  // src/api/index.ts
@@ -88,31 +85,36 @@ import type {
88
85
  KeysetPaginationResult as ApiUtilsKeysetPaginationResult,
89
86
  OffsetPaginationResult as ApiUtilsOffsetPaginationResult,
90
87
  } from '@wisemen/vue-core-api-utils'
91
- import { createApiUtils } from '@wisemen/vue-core-api-utils'
92
-
93
- import type { ProjectQueryKeys } from '@/types/queryKey.type'
94
-
95
- // Define your error codes
96
- export type ERROR_KEYS = 'NOT_FOUND' | 'UNAUTHORIZED' | 'NETWORK_ERROR' | 'VALIDATION_ERROR'
97
-
98
- // Create factory with your types
99
- export const {
100
- useKeysetInfiniteQuery,
88
+ import {
89
+ QueryClient,
90
+ getTanstackQueryClient,
91
+ useQuery,
101
92
  useMutation,
102
93
  useOffsetInfiniteQuery,
94
+ useKeysetInfiniteQuery,
95
+ } from '@wisemen/vue-core-api-utils'
96
+
97
+ // Re-export composables for convenience
98
+ export {
103
99
  useQuery,
104
- usePrefetchKeysetInfiniteQuery,
105
- usePrefetchOffsetInfiniteQuery,
106
- usePrefetchQuery,
107
- useQueryClient,
108
- } = createApiUtils<ProjectQueryKeys, ERROR_KEYS>()
100
+ useMutation,
101
+ useOffsetInfiniteQuery,
102
+ useKeysetInfiniteQuery,
103
+ }
104
+
105
+ // Create a typed QueryClient helper
106
+ export function useQueryClient() {
107
+ return new QueryClient(getTanstackQueryClient())
108
+ }
109
109
 
110
110
  // Export typed result types
111
- export type ApiResult<T> = ApiUtilsApiResult<T, ERROR_KEYS>
112
- export type OffsetPaginationResult<T> = ApiUtilsOffsetPaginationResult<T, ERROR_KEYS>
113
- export type KeysetPaginationResult<T> = ApiUtilsKeysetPaginationResult<T, ERROR_KEYS>
111
+ export type ApiResult<T> = ApiUtilsApiResult<T>
112
+ export type OffsetPaginationResult<T> = ApiUtilsOffsetPaginationResult<T>
113
+ export type KeysetPaginationResult<T> = ApiUtilsKeysetPaginationResult<T>
114
114
  ```
115
115
 
116
+ Composables are imported directly from the package. The `QueryClient` is a type-safe wrapper around the Tanstack QueryClient — its types are inferred from your `Register` augmentation.
117
+
116
118
  ## Core Patterns
117
119
 
118
120
  ### Create a detail query composable
@@ -145,17 +147,17 @@ import { ContactService } from '@/services'
145
147
 
146
148
  export function useCreateContact() {
147
149
  return useMutation({
148
- queryFn: async (options: { body: ContactCreateForm }) => {
149
- return await ContactService.create(options.body)
150
+ queryFn: async ({ body }: { body: ContactCreateForm }) => {
151
+ return await ContactService.create(body)
150
152
  },
151
153
  queryKeysToInvalidate: {
152
- contactList: {}, // Invalidate all contactList queries after success
154
+ contactList: {},
153
155
  },
154
156
  })
155
157
  }
156
158
  ```
157
159
 
158
- Every mutation should list which queries to invalidate via `queryKeysToInvalidate`.
160
+ Every mutation should list which queries to invalidate via `queryKeysToInvalidate`. An empty object `{}` invalidates all queries with that key.
159
161
 
160
162
  ### Use composables in components
161
163
 
@@ -174,7 +176,7 @@ const { result, refetch } = useContactDetail(props.contactUuid)
174
176
  Name: {{ result.getValue().name }}
175
177
  </div>
176
178
  <div v-else-if="result.isErr()">
177
- Error: {{ result.getError().detail }}
179
+ Error occurred
178
180
  </div>
179
181
  <button @click="refetch">Retry</button>
180
182
  </div>
@@ -183,66 +185,12 @@ const { result, refetch } = useContactDetail(props.contactUuid)
183
185
 
184
186
  All queries and mutations return `AsyncResult` with three states: loading, ok, and err.
185
187
 
186
- ## Common Mistakes
187
-
188
- ### CRITICAL: Forget to initialize apiUtilsPlugin with QueryClient config
189
-
190
- ```typescript
191
- // ❌ Wrong: plugin not initialized
192
- const app = createApp(App)
193
- app.mount('#app')
194
- // Throws: "[api-utils] QueryClient not available..."
195
- ```
196
-
197
- ```typescript
198
- // ✅ Correct: plugin initialized with config
199
- const app = createApp(App)
200
- app.use(apiUtilsPlugin({
201
- defaultOptions: {
202
- queries: { staleTime: 1000 * 60 * 5 },
203
- },
204
- }))
205
- app.mount('#app')
206
- ```
207
-
208
- If you skip the plugin, `createApiUtils()` has no QueryClient and throws immediately.
209
-
210
- Source: `src/config/config.ts` — `getQueryClient()` assertion
211
-
212
- ### HIGH: Define query keys interface without strict entity/params structure
213
-
214
- ```typescript
215
- // ❌ Wrong: inconsistent structure
216
- export interface ProjectQueryKeys {
217
- contactDetail: { entity: Contact } // Missing params!
218
- contactList: Contact[] // Should wrap in { entity, params }
219
- }
220
- ```
221
-
222
- ```typescript
223
- // ✅ Correct: every key has entity and params
224
- export interface ProjectQueryKeys {
225
- contactDetail: {
226
- entity: Contact
227
- params: { contactUuid: string }
228
- }
229
- contactList: {
230
- entity: Contact[]
231
- params: { page: number; limit: number }
232
- }
233
- }
234
- ```
235
-
236
- Query keys without proper structure prevent the factory from typing composables correctly and cause runtime errors during query key resolution.
237
-
238
- Source: `docs/packages/api-utils/pages/getting-started/installation.md` Section 3
239
-
240
188
  ## You're all set!
241
189
 
242
190
  You now have:
243
191
  - ✅ Plugin initialized with Vue Query
244
- - ✅ Query keys defined with types
245
- - ✅ API composables created
192
+ - ✅ Query keys registered via module augmentation
193
+ - ✅ Typed QueryClient wrapper created
246
194
  - ✅ Error codes enumerated
247
195
 
248
- Head to [writing-queries](../writing-queries/SKILL.md) to fetch your first resource, or [handling-asyncresult-types](../asyncresult-handling/SKILL.md) to understand the three-state AsyncResult type.
196
+ Head to [writing-queries](../writing-queries/SKILL.md) to fetch your first resource, or [asyncresult-handling](../asyncresult-handling/SKILL.md) to understand the three-state AsyncResult type.
@@ -4,12 +4,6 @@ description: >
4
4
  Combining mutations, cache updates, and AsyncResult to create responsive UIs with instant feedback; optimistic updates with error handling, async transitions, immediate user feedback without request latency.
5
5
  type: core
6
6
  library: vue-core-api-utils
7
- library_version: "0.0.3"
8
- sources:
9
- - "wisemen-digital/wisemen-core:docs/packages/api-utils/pages/usage/mutation.md"
10
- - "wisemen-digital/wisemen-core:docs/packages/api-utils/pages/usage/query-client.md"
11
- - "wisemen-digital/wisemen-core:packages/web/api-utils/src/composables/mutation/mutation.composable.ts"
12
- - "wisemen-digital/wisemen-core:packages/web/api-utils/src/utils/query-client/queryClient.ts"
13
7
  ---
14
8
 
15
9
  # @wisemen/vue-core-api-utils — Optimistic UIs
@@ -31,26 +25,24 @@ const { result: contact } = useQuery('contactDetail', {
31
25
  })
32
26
 
33
27
  const { execute, isLoading, result: mutationResult } = useMutation({
34
- queryFn: ({ body }) => ContactService.updateContact(contactUuid, body),
28
+ queryFn: ({ body }: { body: ContactUpdateForm }) =>
29
+ ContactService.updateContact(contactUuid, body),
35
30
  queryKeysToInvalidate: { contactList: {} },
36
31
  })
37
32
 
38
- async function handleSubmit(formData) {
39
- // Save original (for rollback)
40
- const originalContact = contact.value.isOk() ? contact.value.getValue() : null
41
-
42
- // Optimistic update: immediate cache change
43
- queryClient.update(['contactDetail', { contactUuid }], {
33
+ async function handleSubmit(formData: ContactUpdateForm) {
34
+ // Optimistic update — returns { rollback } for reverting on error
35
+ const { rollback } = queryClient.update(['contactDetail', { contactUuid }], {
44
36
  by: (c) => true,
45
37
  value: (c) => ({ ...c, ...formData }),
46
38
  })
47
39
 
48
40
  // Execute mutation
49
- const result = await execute(formData)
41
+ const result = await execute({ body: formData })
50
42
 
51
43
  // On error, rollback
52
44
  if (result.isErr()) {
53
- queryClient.set(['contactDetail', { contactUuid }], originalContact)
45
+ rollback()
54
46
  }
55
47
  }
56
48
  ```
@@ -60,29 +52,32 @@ async function handleSubmit(formData) {
60
52
  ### Immediate cache update while request pending
61
53
 
62
54
  ```typescript
55
+ const queryClient = useQueryClient()
56
+
63
57
  const { result } = useQuery('contactDetail', {
64
- params: computed(() => ({ contactUuid })),
58
+ params: { contactUuid: computed(() => contactUuid) },
65
59
  queryFn: () => ContactService.getDetail(contactUuid),
66
60
  })
67
61
 
68
62
  const { execute, isLoading } = useMutation({
69
- queryFn: (data) => ContactService.updateContact(contactUuid, data),
70
- queryKeysToInvalidate: { /* ... */ },
63
+ queryFn: ({ body }: { body: ContactUpdateForm }) =>
64
+ ContactService.updateContact(contactUuid, body),
65
+ queryKeysToInvalidate: { contactList: {} },
71
66
  })
72
67
 
73
- async function handleSave(formData) {
74
- // Cache update happens immediately
75
- queryClient.update(['contactDetail', { contactUuid }], {
68
+ async function handleSave(formData: ContactUpdateForm) {
69
+ // Cache update happens immediately, rollback returned for error case
70
+ const { rollback } = queryClient.update(['contactDetail', { contactUuid }], {
76
71
  by: (c) => true,
77
72
  value: (c) => ({ ...c, ...formData }),
78
73
  })
79
74
 
80
75
  // Mutation executes in background
81
- await execute(formData)
76
+ const result = await execute({ body: formData })
82
77
 
83
- // UI shows updated data from cache right away
84
- // isLoading is true while request pending
85
- // result.value changes when mutation completes
78
+ if (result.isErr()) {
79
+ rollback()
80
+ }
86
81
  }
87
82
  ```
88
83
 
@@ -91,71 +86,61 @@ Users see changes instantly. `isLoading` stays true during request, giving visua
91
86
  ### Error handling with AsyncResult
92
87
 
93
88
  ```typescript
94
- async function handleSave(formData) {
95
- const originalContact = contact.value?.getValue()
96
-
97
- queryClient.update(['contactDetail', { contactUuid }], {
89
+ async function handleSave(formData: ContactUpdateForm) {
90
+ const queryClient = useQueryClient()
91
+
92
+ const { rollback } = queryClient.update(['contactDetail', { contactUuid }], {
98
93
  by: (c) => true,
99
94
  value: (c) => ({ ...c, ...formData }),
100
95
  })
101
96
 
102
- const result = await execute(formData)
103
-
104
- // Match on mutation result
105
- result.match({
106
- ok: () => {
107
- // Server confirmed the update
108
- // Cache already reflects the change
109
- showSuccessMessage('Contact updated')
110
- },
111
- err: (error) => {
112
- // Revert optimistic update
113
- queryClient.set(['contactDetail', { contactUuid }], originalContact)
114
- showErrorMessage(`Failed: ${error.message}`)
115
- },
116
- loading: () => {
117
- // Should not happen after await, but handle just in case
118
- },
119
- })
97
+ const result = await execute({ body: formData })
98
+
99
+ if (result.isOk()) {
100
+ showSuccessMessage('Contact updated')
101
+ } else if (result.isErr()) {
102
+ rollback()
103
+ const error = result.getError()
104
+ if ('errors' in error) {
105
+ showErrorMessage(`Failed: ${error.errors[0].detail}`)
106
+ } else {
107
+ showErrorMessage('An unexpected error occurred')
108
+ }
109
+ }
120
110
  }
121
111
  ```
122
112
 
123
- When mutation fails, revert the optimistic change using the saved original value.
113
+ When mutation fails, call `rollback()` to revert the optimistic cache change. Narrow the error type with `'errors' in error` to distinguish expected API errors from unexpected ones.
124
114
 
125
115
  ### Composable combining query + mutation + optimistic UI
126
116
 
127
117
  ```typescript
128
- export function useContactEditor(contactUuid) {
118
+ export function useContactEditor(contactUuid: string) {
129
119
  const queryClient = useQueryClient()
130
120
 
131
121
  const { result: contact } = useQuery('contactDetail', {
132
- params: computed(() => ({ contactUuid })),
122
+ params: { contactUuid: computed(() => contactUuid) },
133
123
  queryFn: () => ContactService.getDetail(contactUuid),
134
124
  })
135
125
 
136
126
  const { execute, isLoading, result: mutationResult } = useMutation({
137
- queryFn: (data) => ContactService.updateContact(contactUuid, data),
127
+ queryFn: ({ body }: { body: ContactUpdateForm }) =>
128
+ ContactService.updateContact(contactUuid, body),
138
129
  queryKeysToInvalidate: {
139
- contactList: () => true,
140
- 'contact-stats': () => true,
130
+ contactList: {},
141
131
  },
142
132
  })
143
133
 
144
- async function saveContact(formData) {
145
- const original = contact.value?.getValue()
146
-
147
- // Optimistic
148
- queryClient.update(['contactDetail', { contactUuid }], {
134
+ async function saveContact(formData: ContactUpdateForm) {
135
+ const { rollback } = queryClient.update(['contactDetail', { contactUuid }], {
149
136
  by: () => true,
150
137
  value: (c) => ({ ...c, ...formData }),
151
138
  })
152
139
 
153
- // Execute
154
- const result = await execute(formData)
140
+ const result = await execute({ body: formData })
155
141
 
156
- // Rollback on error
157
142
  if (result.isErr()) {
158
- queryClient.set(['contactDetail', { contactUuid }], original)
143
+ rollback()
159
144
  }
160
145
 
161
146
  return result
@@ -172,228 +157,22 @@ export function useContactEditor(contactUuid) {
172
157
 
173
158
  Encapsulate the full flow in a composable for reusability across components.
174
159
 
175
- ## Common Mistakes
176
-
177
- ### HIGH: Forget to save original data before optimistic update; can't rollback on error
178
-
179
- ```typescript
180
- // ❌ Wrong: no original saved, rollback impossible
181
- const { execute, result } = useMutation({
182
- queryFn: (data) => ContactService.updateContact(data),
183
- queryKeysToInvalidate: { contactList: () => true },
184
- })
185
-
186
- async function handleSave(formData) {
187
- // Update cache immediately
188
- queryClient.update(['contactDetail', { contactUuid }], {
189
- by: () => true,
190
- value: (c) => ({ ...c, ...formData }),
191
- })
192
-
193
- const result = await execute(formData)
194
-
195
- if (result.isErr()) {
196
- // No way to undo the optimistic change!
197
- showErrorMessage('Save failed')
198
- }
199
- }
200
- ```
201
-
202
- ```typescript
203
- // ✅ Correct: save original before update
204
- async function handleSave(formData) {
205
- // Save original FIRST
206
- const originalContact = contact.value?.getValue()
207
-
208
- // Update cache
209
- queryClient.update(['contactDetail', { contactUuid }], {
210
- by: () => true,
211
- value: (c) => ({ ...c, ...formData }),
212
- })
213
-
214
- const result = await execute(formData)
215
-
216
- if (result.isErr()) {
217
- // Restore original
218
- queryClient.set(['contactDetail', { contactUuid }], originalContact)
219
- showErrorMessage('Save failed, changes reverted')
220
- }
221
- }
222
- ```
223
-
224
- Always save the current value before modifying the cache. Use it to rollback if the mutation fails.
225
-
226
- Source: `docs/packages/api-utils/pages/usage/query-client.md` Real-World Example
227
-
228
- ### CRITICAL: Handle stale optimistic updates; if component unmounts, optimistic change remains in cache
229
-
230
- ```typescript
231
- // ❌ Wrong: optimistic update lives in cache even if user navigates away
232
- async function handleSave(formData) {
233
- const originalContact = contact.value?.getValue()
234
-
235
- queryClient.update(['contactDetail', { contactUuid }], {
236
- by: () => true,
237
- value: (c) => ({ ...c, ...formData }),
238
- })
239
-
240
- // User navigates away while mutation pending
241
- // Cache still has optimistic data
242
- // When user returns, data is stale
243
- const result = await execute(formData)
244
- if (result.isErr()) {
245
- // Rollback happens but user is gone
246
- queryClient.set(['contactDetail', { contactUuid }], originalContact)
247
- }
248
- }
249
- ```
250
-
251
- ```typescript
252
- // ✅ Correct: clear optimistic flag or track mutation completion
253
- import { onUnmounted } from 'vue'
254
-
255
- let originalContact = null
256
- let isSaving = false
257
-
258
- async function handleSave(formData) {
259
- originalContact = contact.value?.getValue()
260
- isSaving = true
261
-
262
- queryClient.update(['contactDetail', { contactUuid }], {
263
- by: () => true,
264
- value: (c) => ({ ...c, ...formData }),
265
- })
266
-
267
- const result = await execute(formData)
268
- isSaving = false
269
-
270
- if (result.isErr() && originalContact) {
271
- queryClient.set(['contactDetail', { contactUuid }], originalContact)
272
- }
273
- }
274
-
275
- onUnmounted(() => {
276
- // If still saving and user leaves, rollback
277
- if (isSaving && originalContact) {
278
- queryClient.set(['contactDetail', { contactUuid }], originalContact)
279
- }
280
- })
281
- ```
282
-
283
- If user navigates away while a mutation is pending, rollback the optimistic change using component lifecycle hooks. Otherwise, stale data persists in the cache.
284
-
285
- Source: Architectural consideration from cache invalidation patterns
286
-
287
- ### HIGH: Miss querying the correct data structure; optimistic update patches wrong field
288
-
289
- ```typescript
290
- // ❌ Wrong: update assuming flat entity, but query returns paginated structure
291
- const { result: paginatedContacts } = useQuery('contactList', {
292
- params: computed(() => ({ limit: 20, offset: 0 })),
293
- queryFn: () => ContactService.list({ limit: 20, offset: 0 }),
294
- })
295
-
296
- queryClient.update('contactList', {
297
- by: (contact) => contact.id === '123',
298
- value: (contact) => ({ ...contact, name: 'Updated' }),
299
- })
300
- // This fails because contactList is not Contact[] but { data: Contact[], meta: {...} }
301
- ```
302
-
303
- ```typescript
304
- // ✅ Correct: QueryClient handles nested structures automatically
305
- const { result: paginatedContacts } = useQuery('contactList', {
306
- params: computed(() => ({ limit: 20, offset: 0 })),
307
- queryFn: () => ContactService.list({ limit: 20, offset: 0 }),
308
- })
309
-
310
- // QueryClient infers the data structure from query keys
311
- // If contactList query returns { data: Contact[], meta: {...} }
312
- // QueryClient knows to iterate contact.data and apply the predicate
313
- queryClient.update('contactList', {
314
- by: (contact) => contact.id === '123',
315
- value: (contact) => ({ ...contact, name: 'Updated' }),
316
- })
317
- ```
318
-
319
- QueryClient automatically extracts the entity from nested query results (`{ data: [...], meta: {...} }`). You work with the flattened entity, QueryClient handles the nesting. This is why checking your query key definition matters.
320
-
321
- Source: `packages/web/api-utils/src/utils/query-client/queryClient.ts`
322
-
323
- ### MEDIUM: Race condition: multiple mutations affect the same query, order matters
324
-
325
- ```typescript
326
- // ❌ Wrong: two mutations in flight affecting the same cache
327
- async function handleMultipleSaves() {
328
- // Mutation 1: add tag
329
- queryClient.update(['contactDetail', { contactUuid }], {
330
- by: () => true,
331
- value: (c) => ({ ...c, tags: [...c.tags, 'new-tag'] }),
332
- })
333
- const result1 = await execute({ tags: [...contact.value.tags, 'new-tag'] })
334
-
335
- // Mutation 2: change name
336
- // But Mutation 1 is still in flight!
337
- queryClient.update(['contactDetail', { contactUuid }], {
338
- by: () => true,
339
- value: (c) => ({ ...c, name: 'Updated' }),
340
- })
341
- const result2 = await execute({ name: 'Updated' })
342
-
343
- // If Mutation 2 completes before Mutation 1, the tags are lost
344
- }
345
- ```
346
-
347
- ```typescript
348
- // ✅ Correct: serialize mutations or track multiple originals
349
- async function handleMultipleSaves() {
350
- const original = contact.value?.getValue()
351
-
352
- // Either: await each mutation before starting the next
353
- queryClient.update(['contactDetail', { contactUuid }], {
354
- by: () => true,
355
- value: (c) => ({ ...c, tags: [...c.tags, 'new-tag'] }),
356
- })
357
- const result1 = await execute({ tags: [...contact.value.tags, 'new-tag'] })
358
- if (result1.isErr()) {
359
- queryClient.set(['contactDetail', { contactUuid }], original)
360
- return
361
- }
362
-
363
- // Now safe to do second mutation
364
- const updatedOriginal = queryClient.get(['contactDetail', { contactUuid }])
365
- queryClient.update(['contactDetail', { contactUuid }], {
366
- by: () => true,
367
- value: (c) => ({ ...c, name: 'Updated' }),
368
- })
369
- const result2 = await execute({ name: 'Updated' })
370
- if (result2.isErr()) {
371
- queryClient.set(['contactDetail', { contactUuid }], updatedOriginal)
372
- }
373
- }
374
- ```
375
-
376
- Mutations should be serialized (one at a time) or you need to track the state after each optimistic update. Concurrent mutations on the same cache lead to lost updates.
377
-
378
- Source: Architectural consideration from query lifecycle patterns
379
-
380
160
  ## Rollback Strategy
381
161
 
382
- Rollback is enabled by saving the original data before the optimistic update:
162
+ `queryClient.update()` returns a `{ rollback }` function that reverts the cache to its previous state:
383
163
 
384
164
  ```typescript
385
- const original = contact.value?.getValue()
386
- queryClient.update(['contactDetail', { contactUuid }], {
165
+ const { rollback } = queryClient.update(['contactDetail', { contactUuid }], {
387
166
  by: () => true,
388
167
  value: (c) => ({ ...c, ...formData }),
389
168
  })
390
- const result = await execute(formData)
169
+ const result = await execute({ body: formData })
391
170
  if (result.isErr()) {
392
- queryClient.set(['contactDetail', { contactUuid }], original)
171
+ rollback()
393
172
  }
394
173
  ```
395
174
 
396
- However, rollback patterns for complex scenarios (partial field updates, nested objects) are being refined in the library. For now, save and restore the entire entity.
175
+ Always use the built-in `rollback()` rather than manually saving and restoring the original data it handles all edge cases including list updates and concurrent modifications.
397
176
 
398
177
  ## See Also
399
178