@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.
- package/dist/index.d.mts +254 -281
- package/dist/index.mjs +246 -307
- package/package.json +3 -2
- package/skills/asyncresult-handling/SKILL.md +0 -79
- package/skills/cache-management/SKILL.md +14 -79
- package/skills/foundations/SKILL.md +8 -160
- package/skills/getting-started/SKILL.md +57 -109
- package/skills/optimistic-uis/SKILL.md +52 -273
- package/skills/writing-infinitequeries/SKILL.md +11 -143
- package/skills/writing-mutations/SKILL.md +11 -131
- package/skills/writing-queries/SKILL.md +1 -115
|
@@ -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,
|
|
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
|
|
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.
|
|
21
|
+
### 2. Register your query keys via module augmentation
|
|
27
22
|
|
|
28
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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`
|
|
76
|
+
The `apiUtilsPlugin` creates a QueryClient with your config and handles @tanstack/vue-query setup internally.
|
|
80
77
|
|
|
81
|
-
### 4. Create your API
|
|
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 {
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
112
|
-
export type OffsetPaginationResult<T> = ApiUtilsOffsetPaginationResult<T
|
|
113
|
-
export type KeysetPaginationResult<T> = ApiUtilsKeysetPaginationResult<T
|
|
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 (
|
|
149
|
-
return await ContactService.create(
|
|
150
|
+
queryFn: async ({ body }: { body: ContactCreateForm }) => {
|
|
151
|
+
return await ContactService.create(body)
|
|
150
152
|
},
|
|
151
153
|
queryKeysToInvalidate: {
|
|
152
|
-
contactList: {},
|
|
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
|
|
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
|
|
245
|
-
- ✅
|
|
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 [
|
|
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 }
|
|
28
|
+
queryFn: ({ body }: { body: ContactUpdateForm }) =>
|
|
29
|
+
ContactService.updateContact(contactUuid, body),
|
|
35
30
|
queryKeysToInvalidate: { contactList: {} },
|
|
36
31
|
})
|
|
37
32
|
|
|
38
|
-
async function handleSubmit(formData) {
|
|
39
|
-
//
|
|
40
|
-
const
|
|
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
|
-
|
|
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(() =>
|
|
58
|
+
params: { contactUuid: computed(() => contactUuid) },
|
|
65
59
|
queryFn: () => ContactService.getDetail(contactUuid),
|
|
66
60
|
})
|
|
67
61
|
|
|
68
62
|
const { execute, isLoading } = useMutation({
|
|
69
|
-
queryFn: (
|
|
70
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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(() =>
|
|
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: (
|
|
127
|
+
queryFn: ({ body }: { body: ContactUpdateForm }) =>
|
|
128
|
+
ContactService.updateContact(contactUuid, body),
|
|
138
129
|
queryKeysToInvalidate: {
|
|
139
|
-
contactList:
|
|
140
|
-
'contact-stats': () => true,
|
|
130
|
+
contactList: {},
|
|
141
131
|
},
|
|
142
132
|
})
|
|
143
133
|
|
|
144
|
-
async function saveContact(formData) {
|
|
145
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
+
`queryClient.update()` returns a `{ rollback }` function that reverts the cache to its previous state:
|
|
383
163
|
|
|
384
164
|
```typescript
|
|
385
|
-
const
|
|
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
|
-
|
|
171
|
+
rollback()
|
|
393
172
|
}
|
|
394
173
|
```
|
|
395
174
|
|
|
396
|
-
|
|
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
|
|