@wisemen/vue-core-api-utils 2.0.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.mjs +37 -12907
- package/package.json +2 -1
- package/skills/asyncresult-handling/SKILL.md +0 -77
- package/skills/cache-management/SKILL.md +36 -138
- package/skills/foundations/SKILL.md +8 -159
- package/skills/getting-started/SKILL.md +54 -142
- package/skills/optimistic-uis/SKILL.md +58 -222
- package/skills/writing-infinitequeries/SKILL.md +14 -212
- package/skills/writing-mutations/SKILL.md +15 -160
- package/skills/writing-queries/SKILL.md +0 -126
|
@@ -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, register typed query keys via module augmentation,
|
|
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.2.0"
|
|
8
|
-
sources:
|
|
9
|
-
- "wisemen-digital/wisemen-core:packages/web/api-utils/src/plugin/apiUtilsPlugin.ts"
|
|
10
|
-
- "wisemen-digital/wisemen-core:packages/web/api-utils/src/register.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,11 +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.
|
|
82
|
-
|
|
83
|
-
Use module augmentation to register your query keys and error codes for library-wide type safety, then re-export composables for convenient importing across your project:
|
|
78
|
+
### 4. Create your API module
|
|
84
79
|
|
|
85
80
|
```typescript
|
|
86
81
|
// src/api/index.ts
|
|
@@ -90,39 +85,35 @@ import type {
|
|
|
90
85
|
KeysetPaginationResult as ApiUtilsKeysetPaginationResult,
|
|
91
86
|
OffsetPaginationResult as ApiUtilsOffsetPaginationResult,
|
|
92
87
|
} from '@wisemen/vue-core-api-utils'
|
|
88
|
+
import {
|
|
89
|
+
QueryClient,
|
|
90
|
+
getTanstackQueryClient,
|
|
91
|
+
useQuery,
|
|
92
|
+
useMutation,
|
|
93
|
+
useOffsetInfiniteQuery,
|
|
94
|
+
useKeysetInfiniteQuery,
|
|
95
|
+
} from '@wisemen/vue-core-api-utils'
|
|
93
96
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
// Define your error codes
|
|
97
|
-
export type ERROR_KEYS = 'NOT_FOUND' | 'UNAUTHORIZED' | 'NETWORK_ERROR' | 'VALIDATION_ERROR'
|
|
98
|
-
|
|
99
|
-
// Register query keys and error codes via module augmentation
|
|
100
|
-
// This makes all composables fully typed without a factory function
|
|
101
|
-
declare module '@wisemen/vue-core-api-utils' {
|
|
102
|
-
interface Register {
|
|
103
|
-
queryKeys: ProjectQueryKeys
|
|
104
|
-
errorCodes: ERROR_KEYS
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Re-export composables for convenient importing
|
|
97
|
+
// Re-export composables for convenience
|
|
109
98
|
export {
|
|
110
|
-
|
|
99
|
+
useQuery,
|
|
111
100
|
useMutation,
|
|
112
101
|
useOffsetInfiniteQuery,
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
102
|
+
useKeysetInfiniteQuery,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Create a typed QueryClient helper
|
|
106
|
+
export function useQueryClient() {
|
|
107
|
+
return new QueryClient(getTanstackQueryClient())
|
|
108
|
+
}
|
|
118
109
|
|
|
119
110
|
// Export typed result types
|
|
120
|
-
export type ApiResult<T> = ApiUtilsApiResult<T
|
|
121
|
-
export type OffsetPaginationResult<T> = ApiUtilsOffsetPaginationResult<T
|
|
122
|
-
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>
|
|
123
114
|
```
|
|
124
115
|
|
|
125
|
-
|
|
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.
|
|
126
117
|
|
|
127
118
|
## Core Patterns
|
|
128
119
|
|
|
@@ -156,17 +147,17 @@ import { ContactService } from '@/services'
|
|
|
156
147
|
|
|
157
148
|
export function useCreateContact() {
|
|
158
149
|
return useMutation({
|
|
159
|
-
queryFn: async (
|
|
160
|
-
return await ContactService.create(
|
|
150
|
+
queryFn: async ({ body }: { body: ContactCreateForm }) => {
|
|
151
|
+
return await ContactService.create(body)
|
|
161
152
|
},
|
|
162
153
|
queryKeysToInvalidate: {
|
|
163
|
-
contactList: {},
|
|
154
|
+
contactList: {},
|
|
164
155
|
},
|
|
165
156
|
})
|
|
166
157
|
}
|
|
167
158
|
```
|
|
168
159
|
|
|
169
|
-
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.
|
|
170
161
|
|
|
171
162
|
### Use composables in components
|
|
172
163
|
|
|
@@ -185,7 +176,7 @@ const { result, refetch } = useContactDetail(props.contactUuid)
|
|
|
185
176
|
Name: {{ result.getValue().name }}
|
|
186
177
|
</div>
|
|
187
178
|
<div v-else-if="result.isErr()">
|
|
188
|
-
Error
|
|
179
|
+
Error occurred
|
|
189
180
|
</div>
|
|
190
181
|
<button @click="refetch">Retry</button>
|
|
191
182
|
</div>
|
|
@@ -194,91 +185,12 @@ const { result, refetch } = useContactDetail(props.contactUuid)
|
|
|
194
185
|
|
|
195
186
|
All queries and mutations return `AsyncResult` with three states: loading, ok, and err.
|
|
196
187
|
|
|
197
|
-
## Common Mistakes
|
|
198
|
-
|
|
199
|
-
### CRITICAL: Forget to initialize apiUtilsPlugin
|
|
200
|
-
|
|
201
|
-
```typescript
|
|
202
|
-
// ❌ Wrong: plugin not initialized
|
|
203
|
-
const app = createApp(App)
|
|
204
|
-
app.mount('#app')
|
|
205
|
-
// Throws: "[api-utils] QueryClient not available..."
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
```typescript
|
|
209
|
-
// ✅ Correct: plugin initialized with config
|
|
210
|
-
const app = createApp(App)
|
|
211
|
-
app.use(apiUtilsPlugin({
|
|
212
|
-
defaultOptions: {
|
|
213
|
-
queries: { staleTime: 1000 * 60 * 5 },
|
|
214
|
-
},
|
|
215
|
-
}))
|
|
216
|
-
app.mount('#app')
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
Without the plugin, composables have no QueryClient and throw immediately.
|
|
220
|
-
|
|
221
|
-
Source: `src/config/config.ts` — `getQueryClient()` assertion
|
|
222
|
-
|
|
223
|
-
### HIGH: Define query keys interface without strict entity/params structure
|
|
224
|
-
|
|
225
|
-
```typescript
|
|
226
|
-
// ❌ Wrong: inconsistent structure
|
|
227
|
-
export interface ProjectQueryKeys {
|
|
228
|
-
contactDetail: { entity: Contact } // Missing params!
|
|
229
|
-
contactList: Contact[] // Should wrap in { entity, params }
|
|
230
|
-
}
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
```typescript
|
|
234
|
-
// ✅ Correct: every key has entity and params
|
|
235
|
-
export interface ProjectQueryKeys {
|
|
236
|
-
contactDetail: {
|
|
237
|
-
entity: Contact
|
|
238
|
-
params: { contactUuid: string }
|
|
239
|
-
}
|
|
240
|
-
contactList: {
|
|
241
|
-
entity: Contact[]
|
|
242
|
-
params: { search?: string }
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
Query keys without proper structure cause TypeScript errors and prevent correct query key resolution.
|
|
248
|
-
|
|
249
|
-
Source: `src/register.ts` — `RegisteredQueryKeyEntity` and `RegisteredQueryKeyParams` type derivation
|
|
250
|
-
|
|
251
|
-
### HIGH: Skip the module augmentation; composables lose type safety
|
|
252
|
-
|
|
253
|
-
```typescript
|
|
254
|
-
// ❌ Wrong: no declare module block in @/api/index.ts
|
|
255
|
-
// Composables fall back to `object` for query keys and `string` for error codes
|
|
256
|
-
// TypeScript won't catch wrong query key names or invalid error codes
|
|
257
|
-
```
|
|
258
|
-
|
|
259
|
-
```typescript
|
|
260
|
-
// ✅ Correct: declare module augments the Register interface
|
|
261
|
-
declare module '@wisemen/vue-core-api-utils' {
|
|
262
|
-
interface Register {
|
|
263
|
-
queryKeys: ProjectQueryKeys
|
|
264
|
-
errorCodes: ERROR_KEYS
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
// Now useQuery('nonExistentKey', ...) is a compile error
|
|
268
|
-
// And error.code is typed as ERROR_KEYS, not just string
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
Without the `declare module` block the library types fall back to generic `object` and `string`. All query key checks and error code checks become useless at compile time.
|
|
272
|
-
|
|
273
|
-
Source: `src/register.ts`
|
|
274
|
-
|
|
275
188
|
## You're all set!
|
|
276
189
|
|
|
277
190
|
You now have:
|
|
278
191
|
- ✅ Plugin initialized with Vue Query
|
|
279
|
-
- ✅ Query keys
|
|
280
|
-
- ✅
|
|
281
|
-
- ✅ Composables re-exported from `@/api`
|
|
192
|
+
- ✅ Query keys registered via module augmentation
|
|
193
|
+
- ✅ Typed QueryClient wrapper created
|
|
282
194
|
- ✅ Error codes enumerated
|
|
283
195
|
|
|
284
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.
|
|
@@ -1,38 +1,22 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: optimistic-uis
|
|
3
3
|
description: >
|
|
4
|
-
Combining mutations,
|
|
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: "1.2.0"
|
|
8
|
-
sources:
|
|
9
|
-
- "wisemen-digital/wisemen-core:packages/web/api-utils/src/composables/mutation/mutation.composable.ts"
|
|
10
|
-
- "wisemen-digital/wisemen-core:packages/web/api-utils/src/utils/query-client/queryClient.ts"
|
|
11
7
|
---
|
|
12
8
|
|
|
13
9
|
# @wisemen/vue-core-api-utils — Optimistic UIs
|
|
14
10
|
|
|
15
|
-
Create fast, responsive UIs by updating the cache immediately while mutations execute in the background. Combine `useMutation()`, `
|
|
11
|
+
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.
|
|
16
12
|
|
|
17
13
|
## Setup
|
|
18
14
|
|
|
19
15
|
```typescript
|
|
20
|
-
|
|
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'
|
|
16
|
+
import { useMutation, useQueryClient, useQuery } from '@/api'
|
|
32
17
|
import { computed } from 'vue'
|
|
33
18
|
|
|
34
19
|
const queryClient = useQueryClient()
|
|
35
|
-
|
|
36
20
|
const { result: contact } = useQuery('contactDetail', {
|
|
37
21
|
params: {
|
|
38
22
|
contactUuid: computed(() => contactUuid),
|
|
@@ -40,22 +24,23 @@ const { result: contact } = useQuery('contactDetail', {
|
|
|
40
24
|
queryFn: () => ContactService.getDetail(contactUuid),
|
|
41
25
|
})
|
|
42
26
|
|
|
43
|
-
const { execute, result: mutationResult } = useMutation({
|
|
44
|
-
queryFn: ({ body }
|
|
27
|
+
const { execute, isLoading, result: mutationResult } = useMutation({
|
|
28
|
+
queryFn: ({ body }: { body: ContactUpdateForm }) =>
|
|
29
|
+
ContactService.updateContact(contactUuid, body),
|
|
45
30
|
queryKeysToInvalidate: { contactList: {} },
|
|
46
31
|
})
|
|
47
32
|
|
|
48
|
-
async function handleSubmit(formData) {
|
|
49
|
-
// Optimistic update — returns rollback
|
|
50
|
-
const { rollback } = queryClient.update(['contactDetail', { contactUuid }]
|
|
51
|
-
by: () => true,
|
|
33
|
+
async function handleSubmit(formData: ContactUpdateForm) {
|
|
34
|
+
// Optimistic update — returns { rollback } for reverting on error
|
|
35
|
+
const { rollback } = queryClient.update(['contactDetail', { contactUuid }], {
|
|
36
|
+
by: (c) => true,
|
|
52
37
|
value: (c) => ({ ...c, ...formData }),
|
|
53
38
|
})
|
|
54
39
|
|
|
55
40
|
// Execute mutation
|
|
56
41
|
const result = await execute({ body: formData })
|
|
57
42
|
|
|
58
|
-
//
|
|
43
|
+
// On error, rollback
|
|
59
44
|
if (result.isErr()) {
|
|
60
45
|
rollback()
|
|
61
46
|
}
|
|
@@ -69,59 +54,64 @@ async function handleSubmit(formData) {
|
|
|
69
54
|
```typescript
|
|
70
55
|
const queryClient = useQueryClient()
|
|
71
56
|
|
|
72
|
-
const {
|
|
73
|
-
|
|
57
|
+
const { result } = useQuery('contactDetail', {
|
|
58
|
+
params: { contactUuid: computed(() => contactUuid) },
|
|
59
|
+
queryFn: () => ContactService.getDetail(contactUuid),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const { execute, isLoading } = useMutation({
|
|
63
|
+
queryFn: ({ body }: { body: ContactUpdateForm }) =>
|
|
64
|
+
ContactService.updateContact(contactUuid, body),
|
|
74
65
|
queryKeysToInvalidate: { contactList: {} },
|
|
75
66
|
})
|
|
76
67
|
|
|
77
|
-
async function handleSave(formData) {
|
|
78
|
-
// Cache update happens immediately
|
|
79
|
-
const { rollback } = queryClient.update(['contactDetail', { contactUuid }]
|
|
80
|
-
by: () => true,
|
|
68
|
+
async function handleSave(formData: ContactUpdateForm) {
|
|
69
|
+
// Cache update happens immediately, rollback returned for error case
|
|
70
|
+
const { rollback } = queryClient.update(['contactDetail', { contactUuid }], {
|
|
71
|
+
by: (c) => true,
|
|
81
72
|
value: (c) => ({ ...c, ...formData }),
|
|
82
73
|
})
|
|
83
74
|
|
|
84
75
|
// Mutation executes in background
|
|
85
76
|
const result = await execute({ body: formData })
|
|
86
77
|
|
|
87
|
-
// On error, revert to the previous state
|
|
88
78
|
if (result.isErr()) {
|
|
89
79
|
rollback()
|
|
90
80
|
}
|
|
91
81
|
}
|
|
92
82
|
```
|
|
93
83
|
|
|
94
|
-
Users see changes instantly.
|
|
84
|
+
Users see changes instantly. `isLoading` stays true during request, giving visual feedback. No perceived latency.
|
|
95
85
|
|
|
96
86
|
### Error handling with AsyncResult
|
|
97
87
|
|
|
98
88
|
```typescript
|
|
99
|
-
async function handleSave(formData) {
|
|
100
|
-
const
|
|
101
|
-
|
|
89
|
+
async function handleSave(formData: ContactUpdateForm) {
|
|
90
|
+
const queryClient = useQueryClient()
|
|
91
|
+
|
|
92
|
+
const { rollback } = queryClient.update(['contactDetail', { contactUuid }], {
|
|
93
|
+
by: (c) => true,
|
|
102
94
|
value: (c) => ({ ...c, ...formData }),
|
|
103
95
|
})
|
|
104
96
|
|
|
105
97
|
const result = await execute({ body: formData })
|
|
106
98
|
|
|
107
|
-
result.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
loading: () => {
|
|
119
|
-
// Won't happen after await, but required by match()
|
|
120
|
-
},
|
|
121
|
-
})
|
|
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
|
+
}
|
|
122
110
|
}
|
|
123
111
|
```
|
|
124
112
|
|
|
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.
|
|
114
|
+
|
|
125
115
|
### Composable combining query + mutation + optimistic UI
|
|
126
116
|
|
|
127
117
|
```typescript
|
|
@@ -129,28 +119,26 @@ 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
|
-
const { execute, result: mutationResult } = useMutation({
|
|
137
|
-
queryFn: ({ body }
|
|
126
|
+
const { execute, isLoading, result: mutationResult } = useMutation({
|
|
127
|
+
queryFn: ({ body }: { body: ContactUpdateForm }) =>
|
|
128
|
+
ContactService.updateContact(contactUuid, body),
|
|
138
129
|
queryKeysToInvalidate: {
|
|
139
130
|
contactList: {},
|
|
140
131
|
},
|
|
141
132
|
})
|
|
142
133
|
|
|
143
|
-
async function saveContact(formData) {
|
|
144
|
-
|
|
145
|
-
const { rollback } = queryClient.update(['contactDetail', { contactUuid }] as const, {
|
|
134
|
+
async function saveContact(formData: ContactUpdateForm) {
|
|
135
|
+
const { rollback } = queryClient.update(['contactDetail', { contactUuid }], {
|
|
146
136
|
by: () => true,
|
|
147
137
|
value: (c) => ({ ...c, ...formData }),
|
|
148
138
|
})
|
|
149
139
|
|
|
150
|
-
// Execute
|
|
151
140
|
const result = await execute({ body: formData })
|
|
152
141
|
|
|
153
|
-
// Rollback on error
|
|
154
142
|
if (result.isErr()) {
|
|
155
143
|
rollback()
|
|
156
144
|
}
|
|
@@ -161,6 +149,7 @@ export function useContactEditor(contactUuid: string) {
|
|
|
161
149
|
return {
|
|
162
150
|
contact,
|
|
163
151
|
saveContact,
|
|
152
|
+
isLoading,
|
|
164
153
|
mutationResult,
|
|
165
154
|
}
|
|
166
155
|
}
|
|
@@ -168,175 +157,22 @@ export function useContactEditor(contactUuid: string) {
|
|
|
168
157
|
|
|
169
158
|
Encapsulate the full flow in a composable for reusability across components.
|
|
170
159
|
|
|
171
|
-
##
|
|
172
|
-
|
|
173
|
-
### HIGH: Ignore the rollback return from update(); can't revert on error
|
|
174
|
-
|
|
175
|
-
```typescript
|
|
176
|
-
// ❌ Wrong: discarding rollback
|
|
177
|
-
async function handleSave(formData) {
|
|
178
|
-
queryClient.update(['contactDetail', { contactUuid }] as const, {
|
|
179
|
-
by: () => true,
|
|
180
|
-
value: (c) => ({ ...c, ...formData }),
|
|
181
|
-
})
|
|
182
|
-
// rollback discarded!
|
|
183
|
-
|
|
184
|
-
const result = await execute({ body: formData })
|
|
185
|
-
|
|
186
|
-
if (result.isErr()) {
|
|
187
|
-
// No way to undo the optimistic change!
|
|
188
|
-
showErrorMessage('Save failed')
|
|
189
|
-
// UI now shows wrong data permanently
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
```typescript
|
|
195
|
-
// ✅ Correct: capture and use rollback
|
|
196
|
-
async function handleSave(formData) {
|
|
197
|
-
const { rollback } = queryClient.update(['contactDetail', { contactUuid }] as const, {
|
|
198
|
-
by: () => true,
|
|
199
|
-
value: (c) => ({ ...c, ...formData }),
|
|
200
|
-
})
|
|
201
|
-
|
|
202
|
-
const result = await execute({ body: formData })
|
|
203
|
-
|
|
204
|
-
if (result.isErr()) {
|
|
205
|
-
rollback() // Restores previous state automatically
|
|
206
|
-
showErrorMessage('Save failed, changes reverted')
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
`update()` captures the previous state internally and exposes it via `rollback()`. Always capture and use it when doing optimistic updates.
|
|
212
|
-
|
|
213
|
-
Source: `src/utils/query-client/queryClient.ts` — `QueryClientUpdateResult`
|
|
214
|
-
|
|
215
|
-
### CRITICAL: Stale optimistic update if component unmounts during pending mutation
|
|
216
|
-
|
|
217
|
-
```typescript
|
|
218
|
-
// ❌ Wrong: mutation pending, user navigates away, rollback never called
|
|
219
|
-
async function handleSave(formData) {
|
|
220
|
-
const { rollback } = queryClient.update(['contactDetail', { contactUuid }] as const, {
|
|
221
|
-
by: () => true,
|
|
222
|
-
value: (c) => ({ ...c, ...formData }),
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
// User navigates away while mutation is pending
|
|
226
|
-
// Cache still has optimistic data
|
|
227
|
-
const result = await execute({ body: formData })
|
|
228
|
-
if (result.isErr()) {
|
|
229
|
-
rollback() // User is gone — rollback still works but user won't see it
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
```typescript
|
|
235
|
-
// ✅ Correct: rollback on unmount if mutation is still in flight
|
|
236
|
-
import { onUnmounted } from 'vue'
|
|
237
|
-
|
|
238
|
-
let pendingRollback: (() => void) | null = null
|
|
239
|
-
|
|
240
|
-
async function handleSave(formData) {
|
|
241
|
-
const { rollback } = queryClient.update(['contactDetail', { contactUuid }] as const, {
|
|
242
|
-
by: () => true,
|
|
243
|
-
value: (c) => ({ ...c, ...formData }),
|
|
244
|
-
})
|
|
245
|
-
pendingRollback = rollback
|
|
246
|
-
|
|
247
|
-
const result = await execute({ body: formData })
|
|
248
|
-
pendingRollback = null
|
|
249
|
-
|
|
250
|
-
if (result.isErr()) {
|
|
251
|
-
rollback()
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
onUnmounted(() => {
|
|
256
|
-
// If still saving and user leaves, rollback stale optimistic update
|
|
257
|
-
pendingRollback?.()
|
|
258
|
-
})
|
|
259
|
-
```
|
|
260
|
-
|
|
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.
|
|
262
|
-
|
|
263
|
-
Source: Architectural consideration from cache invalidation patterns
|
|
160
|
+
## Rollback Strategy
|
|
264
161
|
|
|
265
|
-
|
|
162
|
+
`queryClient.update()` returns a `{ rollback }` function that reverts the cache to its previous state:
|
|
266
163
|
|
|
267
164
|
```typescript
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
value: (c) => ({ ...c, name: 'Updated' }),
|
|
165
|
+
const { rollback } = queryClient.update(['contactDetail', { contactUuid }], {
|
|
166
|
+
by: () => true,
|
|
167
|
+
value: (c) => ({ ...c, ...formData }),
|
|
272
168
|
})
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
```typescript
|
|
277
|
-
// ✅ Correct: use a specific predicate
|
|
278
|
-
queryClient.update('contactList', {
|
|
279
|
-
by: (contact) => contact.uuid === contactUuid, // Only match the specific item
|
|
280
|
-
value: (contact) => ({ ...contact, name: formData.name }),
|
|
281
|
-
})
|
|
282
|
-
```
|
|
283
|
-
|
|
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.
|
|
285
|
-
|
|
286
|
-
Source: `src/utils/query-client/queryClient.ts` — `updateEntity` iterates arrays
|
|
287
|
-
|
|
288
|
-
### MEDIUM: Race condition: multiple mutations affect the same query
|
|
289
|
-
|
|
290
|
-
```typescript
|
|
291
|
-
// ❌ Wrong: two mutations in flight affecting the same cache
|
|
292
|
-
async function handleMultipleSaves() {
|
|
293
|
-
// Mutation 1 optimistic update
|
|
294
|
-
const { rollback: rollback1 } = queryClient.update(['contactDetail', { contactUuid }] as const, {
|
|
295
|
-
by: () => true,
|
|
296
|
-
value: (c) => ({ ...c, tags: [...c.tags, 'new-tag'] }),
|
|
297
|
-
})
|
|
298
|
-
// Don't await — start Mutation 2 immediately!
|
|
299
|
-
execute1({ body: { tags: [...contact.tags, 'new-tag'] } })
|
|
300
|
-
|
|
301
|
-
// Mutation 2 on same cache entry
|
|
302
|
-
const { rollback: rollback2 } = queryClient.update(['contactDetail', { contactUuid }] as const, {
|
|
303
|
-
by: () => true,
|
|
304
|
-
value: (c) => ({ ...c, name: 'Updated' }),
|
|
305
|
-
})
|
|
306
|
-
await execute2({ body: { name: 'Updated' } })
|
|
307
|
-
|
|
308
|
-
// If Mutation 2 completes before Mutation 1, the tags may be lost
|
|
309
|
-
}
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
```typescript
|
|
313
|
-
// ✅ Correct: serialize mutations to avoid ordering issues
|
|
314
|
-
async function handleMultipleSaves() {
|
|
315
|
-
const { rollback: rollback1 } = queryClient.update(['contactDetail', { contactUuid }] as const, {
|
|
316
|
-
by: () => true,
|
|
317
|
-
value: (c) => ({ ...c, tags: [...c.tags, 'new-tag'] }),
|
|
318
|
-
})
|
|
319
|
-
const result1 = await execute1({ body: { tags: [...contact.tags, 'new-tag'] } })
|
|
320
|
-
if (result1.isErr()) {
|
|
321
|
-
rollback1()
|
|
322
|
-
return
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// Only start Mutation 2 after Mutation 1 finishes
|
|
326
|
-
const { rollback: rollback2 } = queryClient.update(['contactDetail', { contactUuid }] as const, {
|
|
327
|
-
by: () => true,
|
|
328
|
-
value: (c) => ({ ...c, name: 'Updated' }),
|
|
329
|
-
})
|
|
330
|
-
const result2 = await execute2({ body: { name: 'Updated' } })
|
|
331
|
-
if (result2.isErr()) {
|
|
332
|
-
rollback2()
|
|
333
|
-
}
|
|
169
|
+
const result = await execute({ body: formData })
|
|
170
|
+
if (result.isErr()) {
|
|
171
|
+
rollback()
|
|
334
172
|
}
|
|
335
173
|
```
|
|
336
174
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
Source: Architectural consideration from query lifecycle patterns
|
|
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.
|
|
340
176
|
|
|
341
177
|
## See Also
|
|
342
178
|
|