@wisemen/vue-core-api-utils 1.1.0 → 2.0.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.
@@ -1,28 +1,38 @@
1
1
  ---
2
2
  name: optimistic-uis
3
3
  description: >
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.
4
+ Combining mutations, QueryClient.update() with built-in rollback, and AsyncResult to create responsive UIs with instant feedback; optimistic updates with automatic error reversal.
5
5
  type: core
6
6
  library: vue-core-api-utils
7
- library_version: "0.0.3"
7
+ library_version: "1.2.0"
8
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
9
  - "wisemen-digital/wisemen-core:packages/web/api-utils/src/composables/mutation/mutation.composable.ts"
12
10
  - "wisemen-digital/wisemen-core:packages/web/api-utils/src/utils/query-client/queryClient.ts"
13
11
  ---
14
12
 
15
13
  # @wisemen/vue-core-api-utils — Optimistic UIs
16
14
 
17
- Create fast, responsive UIs by updating the cache immediately while mutations execute in the background. Combine `useMutation()`, `useQueryClient()`, and `AsyncResult` pattern matching to provide instant feedback to users.
15
+ Create fast, responsive UIs by updating the cache immediately while mutations execute in the background. Combine `useMutation()`, `QueryClient`, and `AsyncResult` pattern matching to provide instant feedback to users.
18
16
 
19
17
  ## Setup
20
18
 
21
19
  ```typescript
22
- import { useMutation, useQueryClient, useQuery } from '@/api'
20
+ // src/api/queryClient.ts
21
+ import { QueryClient, getTanstackQueryClient } from '@wisemen/vue-core-api-utils'
22
+ import type { ProjectQueryKeys } from '@/types/queryKey.type'
23
+
24
+ export function useQueryClient() {
25
+ return new QueryClient<ProjectQueryKeys>(getTanstackQueryClient())
26
+ }
27
+ ```
28
+
29
+ ```typescript
30
+ import { useQuery, useMutation } from '@/api'
31
+ import { useQueryClient } from '@/api/queryClient'
23
32
  import { computed } from 'vue'
24
33
 
25
34
  const queryClient = useQueryClient()
35
+
26
36
  const { result: contact } = useQuery('contactDetail', {
27
37
  params: {
28
38
  contactUuid: computed(() => contactUuid),
@@ -30,27 +40,24 @@ const { result: contact } = useQuery('contactDetail', {
30
40
  queryFn: () => ContactService.getDetail(contactUuid),
31
41
  })
32
42
 
33
- const { execute, isLoading, result: mutationResult } = useMutation({
43
+ const { execute, result: mutationResult } = useMutation({
34
44
  queryFn: ({ body }) => ContactService.updateContact(contactUuid, body),
35
45
  queryKeysToInvalidate: { contactList: {} },
36
46
  })
37
47
 
38
48
  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 }], {
44
- by: (c) => true,
49
+ // Optimistic update returns rollback function
50
+ const { rollback } = queryClient.update(['contactDetail', { contactUuid }] as const, {
51
+ by: () => true,
45
52
  value: (c) => ({ ...c, ...formData }),
46
53
  })
47
54
 
48
55
  // Execute mutation
49
- const result = await execute(formData)
56
+ const result = await execute({ body: formData })
50
57
 
51
- // On error, rollback
58
+ // Revert cache on error
52
59
  if (result.isErr()) {
53
- queryClient.set(['contactDetail', { contactUuid }], originalContact)
60
+ rollback()
54
61
  }
55
62
  }
56
63
  ```
@@ -60,72 +67,65 @@ async function handleSubmit(formData) {
60
67
  ### Immediate cache update while request pending
61
68
 
62
69
  ```typescript
63
- const { result } = useQuery('contactDetail', {
64
- params: computed(() => ({ contactUuid })),
65
- queryFn: () => ContactService.getDetail(contactUuid),
66
- })
70
+ const queryClient = useQueryClient()
67
71
 
68
- const { execute, isLoading } = useMutation({
72
+ const { execute } = useMutation({
69
73
  queryFn: (data) => ContactService.updateContact(contactUuid, data),
70
- queryKeysToInvalidate: { /* ... */ },
74
+ queryKeysToInvalidate: { contactList: {} },
71
75
  })
72
76
 
73
77
  async function handleSave(formData) {
74
- // Cache update happens immediately
75
- queryClient.update(['contactDetail', { contactUuid }], {
76
- by: (c) => true,
78
+ // Cache update happens immediately — UI shows new data right away
79
+ const { rollback } = queryClient.update(['contactDetail', { contactUuid }] as const, {
80
+ by: () => true,
77
81
  value: (c) => ({ ...c, ...formData }),
78
82
  })
79
83
 
80
84
  // Mutation executes in background
81
- await execute(formData)
85
+ const result = await execute({ body: formData })
82
86
 
83
- // UI shows updated data from cache right away
84
- // isLoading is true while request pending
85
- // result.value changes when mutation completes
87
+ // On error, revert to the previous state
88
+ if (result.isErr()) {
89
+ rollback()
90
+ }
86
91
  }
87
92
  ```
88
93
 
89
- Users see changes instantly. `isLoading` stays true during request, giving visual feedback. No perceived latency.
94
+ Users see changes instantly. No perceived latency. If the server rejects the change, `rollback()` restores the previous cache state automatically.
90
95
 
91
96
  ### Error handling with AsyncResult
92
97
 
93
98
  ```typescript
94
99
  async function handleSave(formData) {
95
- const originalContact = contact.value?.getValue()
96
-
97
- queryClient.update(['contactDetail', { contactUuid }], {
98
- by: (c) => true,
100
+ const { rollback } = queryClient.update(['contactDetail', { contactUuid }] as const, {
101
+ by: () => true,
99
102
  value: (c) => ({ ...c, ...formData }),
100
103
  })
101
104
 
102
- const result = await execute(formData)
105
+ const result = await execute({ body: formData })
103
106
 
104
- // Match on mutation result
105
107
  result.match({
106
108
  ok: () => {
107
109
  // Server confirmed the update
108
- // Cache already reflects the change
110
+ // Cache already reflects the change from the optimistic update
109
111
  showSuccessMessage('Contact updated')
110
112
  },
111
113
  err: (error) => {
112
114
  // Revert optimistic update
113
- queryClient.set(['contactDetail', { contactUuid }], originalContact)
114
- showErrorMessage(`Failed: ${error.message}`)
115
+ rollback()
116
+ showErrorMessage(`Failed: ${'errors' in error ? error.errors[0].detail : error.message}`)
115
117
  },
116
118
  loading: () => {
117
- // Should not happen after await, but handle just in case
119
+ // Won't happen after await, but required by match()
118
120
  },
119
121
  })
120
122
  }
121
123
  ```
122
124
 
123
- When mutation fails, revert the optimistic change using the saved original value.
124
-
125
125
  ### Composable combining query + mutation + optimistic UI
126
126
 
127
127
  ```typescript
128
- export function useContactEditor(contactUuid) {
128
+ export function useContactEditor(contactUuid: string) {
129
129
  const queryClient = useQueryClient()
130
130
 
131
131
  const { result: contact } = useQuery('contactDetail', {
@@ -133,29 +133,26 @@ export function useContactEditor(contactUuid) {
133
133
  queryFn: () => ContactService.getDetail(contactUuid),
134
134
  })
135
135
 
136
- const { execute, isLoading, result: mutationResult } = useMutation({
137
- queryFn: (data) => ContactService.updateContact(contactUuid, data),
136
+ const { execute, result: mutationResult } = useMutation({
137
+ queryFn: ({ body }) => ContactService.updateContact(contactUuid, body),
138
138
  queryKeysToInvalidate: {
139
- contactList: () => true,
140
- 'contact-stats': () => true,
139
+ contactList: {},
141
140
  },
142
141
  })
143
142
 
144
143
  async function saveContact(formData) {
145
- const original = contact.value?.getValue()
146
-
147
- // Optimistic
148
- queryClient.update(['contactDetail', { contactUuid }], {
144
+ // Optimistic update
145
+ const { rollback } = queryClient.update(['contactDetail', { contactUuid }] as const, {
149
146
  by: () => true,
150
147
  value: (c) => ({ ...c, ...formData }),
151
148
  })
152
149
 
153
150
  // Execute
154
- const result = await execute(formData)
151
+ const result = await execute({ body: formData })
155
152
 
156
153
  // Rollback on error
157
154
  if (result.isErr()) {
158
- queryClient.set(['contactDetail', { contactUuid }], original)
155
+ rollback()
159
156
  }
160
157
 
161
158
  return result
@@ -164,7 +161,6 @@ export function useContactEditor(contactUuid) {
164
161
  return {
165
162
  contact,
166
163
  saveContact,
167
- isLoading,
168
164
  mutationResult,
169
165
  }
170
166
  }
@@ -174,227 +170,174 @@ Encapsulate the full flow in a composable for reusability across components.
174
170
 
175
171
  ## Common Mistakes
176
172
 
177
- ### HIGH: Forget to save original data before optimistic update; can't rollback on error
173
+ ### HIGH: Ignore the rollback return from update(); can't revert on error
178
174
 
179
175
  ```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
-
176
+ // ❌ Wrong: discarding rollback
186
177
  async function handleSave(formData) {
187
- // Update cache immediately
188
- queryClient.update(['contactDetail', { contactUuid }], {
178
+ queryClient.update(['contactDetail', { contactUuid }] as const, {
189
179
  by: () => true,
190
180
  value: (c) => ({ ...c, ...formData }),
191
181
  })
182
+ // rollback discarded!
192
183
 
193
- const result = await execute(formData)
184
+ const result = await execute({ body: formData })
194
185
 
195
186
  if (result.isErr()) {
196
187
  // No way to undo the optimistic change!
197
188
  showErrorMessage('Save failed')
189
+ // UI now shows wrong data permanently
198
190
  }
199
191
  }
200
192
  ```
201
193
 
202
194
  ```typescript
203
- // ✅ Correct: save original before update
195
+ // ✅ Correct: capture and use rollback
204
196
  async function handleSave(formData) {
205
- // Save original FIRST
206
- const originalContact = contact.value?.getValue()
207
-
208
- // Update cache
209
- queryClient.update(['contactDetail', { contactUuid }], {
197
+ const { rollback } = queryClient.update(['contactDetail', { contactUuid }] as const, {
210
198
  by: () => true,
211
199
  value: (c) => ({ ...c, ...formData }),
212
200
  })
213
201
 
214
- const result = await execute(formData)
202
+ const result = await execute({ body: formData })
215
203
 
216
204
  if (result.isErr()) {
217
- // Restore original
218
- queryClient.set(['contactDetail', { contactUuid }], originalContact)
205
+ rollback() // Restores previous state automatically
219
206
  showErrorMessage('Save failed, changes reverted')
220
207
  }
221
208
  }
222
209
  ```
223
210
 
224
- Always save the current value before modifying the cache. Use it to rollback if the mutation fails.
211
+ `update()` captures the previous state internally and exposes it via `rollback()`. Always capture and use it when doing optimistic updates.
225
212
 
226
- Source: `docs/packages/api-utils/pages/usage/query-client.md` Real-World Example
213
+ Source: `src/utils/query-client/queryClient.ts` `QueryClientUpdateResult`
227
214
 
228
- ### CRITICAL: Handle stale optimistic updates; if component unmounts, optimistic change remains in cache
215
+ ### CRITICAL: Stale optimistic update if component unmounts during pending mutation
229
216
 
230
217
  ```typescript
231
- // ❌ Wrong: optimistic update lives in cache even if user navigates away
218
+ // ❌ Wrong: mutation pending, user navigates away, rollback never called
232
219
  async function handleSave(formData) {
233
- const originalContact = contact.value?.getValue()
234
-
235
- queryClient.update(['contactDetail', { contactUuid }], {
220
+ const { rollback } = queryClient.update(['contactDetail', { contactUuid }] as const, {
236
221
  by: () => true,
237
222
  value: (c) => ({ ...c, ...formData }),
238
223
  })
239
224
 
240
- // User navigates away while mutation pending
225
+ // User navigates away while mutation is pending
241
226
  // Cache still has optimistic data
242
- // When user returns, data is stale
243
- const result = await execute(formData)
227
+ const result = await execute({ body: formData })
244
228
  if (result.isErr()) {
245
- // Rollback happens but user is gone
246
- queryClient.set(['contactDetail', { contactUuid }], originalContact)
229
+ rollback() // User is gone — rollback still works but user won't see it
247
230
  }
248
231
  }
249
232
  ```
250
233
 
251
234
  ```typescript
252
- // ✅ Correct: clear optimistic flag or track mutation completion
235
+ // ✅ Correct: rollback on unmount if mutation is still in flight
253
236
  import { onUnmounted } from 'vue'
254
237
 
255
- let originalContact = null
256
- let isSaving = false
238
+ let pendingRollback: (() => void) | null = null
257
239
 
258
240
  async function handleSave(formData) {
259
- originalContact = contact.value?.getValue()
260
- isSaving = true
261
-
262
- queryClient.update(['contactDetail', { contactUuid }], {
241
+ const { rollback } = queryClient.update(['contactDetail', { contactUuid }] as const, {
263
242
  by: () => true,
264
243
  value: (c) => ({ ...c, ...formData }),
265
244
  })
245
+ pendingRollback = rollback
266
246
 
267
- const result = await execute(formData)
268
- isSaving = false
247
+ const result = await execute({ body: formData })
248
+ pendingRollback = null
269
249
 
270
- if (result.isErr() && originalContact) {
271
- queryClient.set(['contactDetail', { contactUuid }], originalContact)
250
+ if (result.isErr()) {
251
+ rollback()
272
252
  }
273
253
  }
274
254
 
275
255
  onUnmounted(() => {
276
- // If still saving and user leaves, rollback
277
- if (isSaving && originalContact) {
278
- queryClient.set(['contactDetail', { contactUuid }], originalContact)
279
- }
256
+ // If still saving and user leaves, rollback stale optimistic update
257
+ pendingRollback?.()
280
258
  })
281
259
  ```
282
260
 
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.
261
+ If the user navigates away while a mutation is pending, rollback the optimistic change using component lifecycle hooks. Otherwise, stale unconfirmed data persists in the global cache.
284
262
 
285
263
  Source: Architectural consideration from cache invalidation patterns
286
264
 
287
- ### HIGH: Miss querying the correct data structure; optimistic update patches wrong field
265
+ ### HIGH: Optimistic update on list query with wrong predicate; patches wrong item
288
266
 
289
267
  ```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
-
268
+ // ❌ Wrong: update without proper predicate
296
269
  queryClient.update('contactList', {
297
- by: (contact) => contact.id === '123',
298
- value: (contact) => ({ ...contact, name: 'Updated' }),
270
+ by: () => true, // Matches ALL contacts in the list!
271
+ value: (c) => ({ ...c, name: 'Updated' }),
299
272
  })
300
- // This fails because contactList is not Contact[] but { data: Contact[], meta: {...} }
273
+ // Every contact in the cached list gets "Updated" as name
301
274
  ```
302
275
 
303
276
  ```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
277
+ // ✅ Correct: use a specific predicate
313
278
  queryClient.update('contactList', {
314
- by: (contact) => contact.id === '123',
315
- value: (contact) => ({ ...contact, name: 'Updated' }),
279
+ by: (contact) => contact.uuid === contactUuid, // Only match the specific item
280
+ value: (contact) => ({ ...contact, name: formData.name }),
316
281
  })
317
282
  ```
318
283
 
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.
284
+ When updating lists, always use a predicate that uniquely identifies the item you're changing. `by: () => true` is only appropriate for single-entity queries.
320
285
 
321
- Source: `packages/web/api-utils/src/utils/query-client/queryClient.ts`
286
+ Source: `src/utils/query-client/queryClient.ts` — `updateEntity` iterates arrays
322
287
 
323
- ### MEDIUM: Race condition: multiple mutations affect the same query, order matters
288
+ ### MEDIUM: Race condition: multiple mutations affect the same query
324
289
 
325
290
  ```typescript
326
291
  // ❌ Wrong: two mutations in flight affecting the same cache
327
292
  async function handleMultipleSaves() {
328
- // Mutation 1: add tag
329
- queryClient.update(['contactDetail', { contactUuid }], {
293
+ // Mutation 1 optimistic update
294
+ const { rollback: rollback1 } = queryClient.update(['contactDetail', { contactUuid }] as const, {
330
295
  by: () => true,
331
296
  value: (c) => ({ ...c, tags: [...c.tags, 'new-tag'] }),
332
297
  })
333
- const result1 = await execute({ tags: [...contact.value.tags, 'new-tag'] })
298
+ // Don't await start Mutation 2 immediately!
299
+ execute1({ body: { tags: [...contact.tags, 'new-tag'] } })
334
300
 
335
- // Mutation 2: change name
336
- // But Mutation 1 is still in flight!
337
- queryClient.update(['contactDetail', { contactUuid }], {
301
+ // Mutation 2 on same cache entry
302
+ const { rollback: rollback2 } = queryClient.update(['contactDetail', { contactUuid }] as const, {
338
303
  by: () => true,
339
304
  value: (c) => ({ ...c, name: 'Updated' }),
340
305
  })
341
- const result2 = await execute({ name: 'Updated' })
306
+ await execute2({ body: { name: 'Updated' } })
342
307
 
343
- // If Mutation 2 completes before Mutation 1, the tags are lost
308
+ // If Mutation 2 completes before Mutation 1, the tags may be lost
344
309
  }
345
310
  ```
346
311
 
347
312
  ```typescript
348
- // ✅ Correct: serialize mutations or track multiple originals
313
+ // ✅ Correct: serialize mutations to avoid ordering issues
349
314
  async function handleMultipleSaves() {
350
- const original = contact.value?.getValue()
351
-
352
- // Either: await each mutation before starting the next
353
- queryClient.update(['contactDetail', { contactUuid }], {
315
+ const { rollback: rollback1 } = queryClient.update(['contactDetail', { contactUuid }] as const, {
354
316
  by: () => true,
355
317
  value: (c) => ({ ...c, tags: [...c.tags, 'new-tag'] }),
356
318
  })
357
- const result1 = await execute({ tags: [...contact.value.tags, 'new-tag'] })
319
+ const result1 = await execute1({ body: { tags: [...contact.tags, 'new-tag'] } })
358
320
  if (result1.isErr()) {
359
- queryClient.set(['contactDetail', { contactUuid }], original)
321
+ rollback1()
360
322
  return
361
323
  }
362
324
 
363
- // Now safe to do second mutation
364
- const updatedOriginal = queryClient.get(['contactDetail', { contactUuid }])
365
- queryClient.update(['contactDetail', { contactUuid }], {
325
+ // Only start Mutation 2 after Mutation 1 finishes
326
+ const { rollback: rollback2 } = queryClient.update(['contactDetail', { contactUuid }] as const, {
366
327
  by: () => true,
367
328
  value: (c) => ({ ...c, name: 'Updated' }),
368
329
  })
369
- const result2 = await execute({ name: 'Updated' })
330
+ const result2 = await execute2({ body: { name: 'Updated' } })
370
331
  if (result2.isErr()) {
371
- queryClient.set(['contactDetail', { contactUuid }], updatedOriginal)
332
+ rollback2()
372
333
  }
373
334
  }
374
335
  ```
375
336
 
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.
337
+ Serialize mutations that affect the same cache entry. Concurrent mutations on the same key can lead to lost updates or incorrect rollbacks.
377
338
 
378
339
  Source: Architectural consideration from query lifecycle patterns
379
340
 
380
- ## Rollback Strategy
381
-
382
- Rollback is enabled by saving the original data before the optimistic update:
383
-
384
- ```typescript
385
- const original = contact.value?.getValue()
386
- queryClient.update(['contactDetail', { contactUuid }], {
387
- by: () => true,
388
- value: (c) => ({ ...c, ...formData }),
389
- })
390
- const result = await execute(formData)
391
- if (result.isErr()) {
392
- queryClient.set(['contactDetail', { contactUuid }], original)
393
- }
394
- ```
395
-
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.
397
-
398
341
  ## See Also
399
342
 
400
343
  - [Writing Mutations](../writing-mutations/SKILL.md) — The `execute()` and result handling that pairs with optimistic updates