@wisemen/vue-core-api-utils 1.0.1 → 1.2.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.
- package/dist/index.d.mts +25 -18
- package/dist/index.mjs +10 -1
- package/package.json +12 -3
- package/skills/asyncresult-handling/SKILL.md +181 -0
- package/skills/cache-management/SKILL.md +222 -0
- package/skills/foundations/SKILL.md +461 -0
- package/skills/getting-started/SKILL.md +248 -0
- package/skills/optimistic-uis/SKILL.md +402 -0
- package/skills/writing-infinitequeries/SKILL.md +243 -0
- package/skills/writing-mutations/SKILL.md +240 -0
- package/skills/writing-queries/SKILL.md +205 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: foundations
|
|
3
|
+
description: >
|
|
4
|
+
neverthrow Result architectural basis; three-state AsyncResult relationship to Result; @tanstack/vue-query lifecycle (staleTime, gcTime, refetch); composition of TanStack Query + neverthrow + Vue 3 reactivity.
|
|
5
|
+
type: core
|
|
6
|
+
library: vue-core-api-utils
|
|
7
|
+
library_version: "0.0.3"
|
|
8
|
+
sources:
|
|
9
|
+
- "wisemen-digital/wisemen-core:docs/packages/api-utils/pages/concepts/result-types.md"
|
|
10
|
+
- "wisemen-digital/wisemen-core:packages/web/api-utils/src/async-result/asyncResult.ts"
|
|
11
|
+
- "wisemen-digital/wisemen-core:packages/web/api-utils/src/types/apiError.type.ts"
|
|
12
|
+
- "wisemen-digital/wisemen-core:packages/web/api-utils/src/config/config.ts"
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# @wisemen/vue-core-api-utils — Foundations
|
|
16
|
+
|
|
17
|
+
Understand how `AsyncResult` from `neverthrow` and `@tanstack/vue-query` combine to provide structured error handling and reactive query management. This knowledge informs all other skills.
|
|
18
|
+
|
|
19
|
+
## Core Concepts
|
|
20
|
+
|
|
21
|
+
### AsyncResult: The three-state type system
|
|
22
|
+
|
|
23
|
+
`AsyncResult<T, E>` is a Result type from the `neverthrow` library that explicitly models three states:
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
type AsyncResult<T, E> = AsyncResultLoading
|
|
27
|
+
| AsyncResultOk<T>
|
|
28
|
+
| AsyncResultErr<E>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The three states replace traditional Vue composition with separate flags:
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
// ❌ Old pattern: multiple flags
|
|
35
|
+
const isLoading = ref(false)
|
|
36
|
+
const isError = ref(false)
|
|
37
|
+
const data = ref(null)
|
|
38
|
+
const error = ref(null)
|
|
39
|
+
|
|
40
|
+
// Which combinations are valid? isLoading + isError? isLoading + data?
|
|
41
|
+
// The state machine is implicit, error-prone
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
// ✅ AsyncResult: single discriminated union
|
|
46
|
+
const result = ref<AsyncResult<Contact, ApiError>>(new AsyncResultLoading())
|
|
47
|
+
|
|
48
|
+
// Only three valid states; type system enforces them
|
|
49
|
+
// Pattern matching makes every state explicit
|
|
50
|
+
result.value.match({
|
|
51
|
+
loading: () => 'Loading...',
|
|
52
|
+
ok: (contact) => contact.name,
|
|
53
|
+
err: (error) => error.message,
|
|
54
|
+
})
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Three-state representation
|
|
58
|
+
|
|
59
|
+
AsyncResult wraps any promise-based operation:
|
|
60
|
+
|
|
61
|
+
| State | Setup | Usage | Next |
|
|
62
|
+
|-------|-------|-------|------|
|
|
63
|
+
| **Loading** | Initial state when query starts | Show spinner/skeleton | → Ok or Err |
|
|
64
|
+
| **Ok(T)** | Server returned success with data | Show data with `getValue()` | Stays Ok until refetch |
|
|
65
|
+
| **Err(E)** | Server returned error or network failed | Show error with `getError()` | Query can be retried |
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
import { useQuery } from '@/api'
|
|
69
|
+
|
|
70
|
+
const { result } = useQuery('contactDetail', {
|
|
71
|
+
queryFn: () => ContactService.getDetail(uuid),
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// result is computed ref to AsyncResult<Contact, ApiError>
|
|
75
|
+
result.value.match({
|
|
76
|
+
loading: () => <div>Loading</div>,
|
|
77
|
+
ok: (contact) => <div>{contact.name}</div>,
|
|
78
|
+
err: (error) => <div>Error: {error.message}</div>,
|
|
79
|
+
})
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### neverthrow Result vs AsyncResult
|
|
83
|
+
|
|
84
|
+
neverthrow provides `Result<T, E>` for synchronous operations. `AsyncResult` extends it for async:
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
// neverthrow Result: already resolved
|
|
88
|
+
const result: Result<Contact, ApiError> = await contactService.getDetail()
|
|
89
|
+
|
|
90
|
+
result.match({
|
|
91
|
+
ok: (contact) => console.log(contact.name),
|
|
92
|
+
err: (error) => console.error(error.message),
|
|
93
|
+
})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
// AsyncResult: waiting for promise
|
|
98
|
+
const result: AsyncResult<Contact, ApiError> = new AsyncResultLoading()
|
|
99
|
+
|
|
100
|
+
result.match({
|
|
101
|
+
loading: () => console.log('Waiting...'),
|
|
102
|
+
ok: (contact) => console.log(contact.name),
|
|
103
|
+
err: (error) => console.error(error.message),
|
|
104
|
+
})
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
AsyncResult is Result + loading state. Every composable that fetches data returns AsyncResult.
|
|
108
|
+
|
|
109
|
+
### Type guards from neverthrow
|
|
110
|
+
|
|
111
|
+
Safely extract values using type guards:
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
const result = new AsyncResultOk(contact)
|
|
115
|
+
|
|
116
|
+
// Type predicate
|
|
117
|
+
if (result.isOk()) {
|
|
118
|
+
const contact = result.getValue() // No type error; TypeScript knows it's Contact
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const errResult = new AsyncResultErr(error)
|
|
122
|
+
if (errResult.isErr()) {
|
|
123
|
+
const error = errResult.getError() // No type error; TypeScript knows it's ApiError
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!result.isLoading()) {
|
|
127
|
+
// Could be Ok or Err
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## TanStack Query Lifecycle
|
|
132
|
+
|
|
133
|
+
`@tanstack/vue-query` manages the async lifecycle beneath AsyncResult.
|
|
134
|
+
|
|
135
|
+
### Query state machine
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
[Initial]
|
|
139
|
+
↓
|
|
140
|
+
[Fetching] (isLoading)
|
|
141
|
+
↓
|
|
142
|
+
[Stale] (cached data exists but flagged for refresh)
|
|
143
|
+
↓
|
|
144
|
+
[Inactive] (unused queries auto-cleanup after gcTime)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Stale time: How long is cached data fresh?
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
const { result } = useQuery('contactDetail', {
|
|
151
|
+
queryFn: () => ContactService.getDetail(uuid),
|
|
152
|
+
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// Timeline:
|
|
156
|
+
// T=0: First fetch. Result is Ok(contact). freshInterval starts.
|
|
157
|
+
// T=4m59s: Data is still fresh. Returning cached contact instantly.
|
|
158
|
+
// T=5m01s: Data is now stale. Still showing cached contact, but next interaction refetches.
|
|
159
|
+
// T=next-page-view: Fresh fetch triggered automatically.
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Stale time is the **grace period** before the cache is considered outdated. While fresh, subsequent requests return cache instantly without refetching.
|
|
163
|
+
|
|
164
|
+
### Garbage collection time: When does cache disappear?
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
const gcTime = 5 * 60 * 1000 // 5 minutes
|
|
168
|
+
|
|
169
|
+
// Query runs, then becomes unused (component unmounts, user navigates away).
|
|
170
|
+
// For gcTime duration, data is kept in memory (but marked as stale).
|
|
171
|
+
// After gcTime, if query hasn't been accessed, it's deleted from cache.
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
gcTime is cleanup. If you navigate back before gcTime expires, you get the cached (stale) data. After gcTime, next access refetches fresh.
|
|
175
|
+
|
|
176
|
+
### Refetch triggers
|
|
177
|
+
|
|
178
|
+
Queries refetch when:
|
|
179
|
+
|
|
180
|
+
1. **Manual trigger** — `refetch()` function
|
|
181
|
+
2. **Mutation invalidation** — `queryKeysToInvalidate` in mutation definition
|
|
182
|
+
3. **Stale time expired** — Next component interaction after staleTime passes
|
|
183
|
+
4. **Focus refetch** — Window regains focus (configurable)
|
|
184
|
+
5. **Component mount** — If cache is beyond gcTime
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
const { result, refetch } = useQuery('contactDetail', {
|
|
188
|
+
queryFn: () => ContactService.getDetail(uuid),
|
|
189
|
+
staleTime: 5 * 60 * 1000,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// Manual refetch
|
|
193
|
+
async function handleRefresh() {
|
|
194
|
+
await refetch()
|
|
195
|
+
// result updates to new Ok or Err
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Automatic refetch on mutation (via queryKeysToInvalidate)
|
|
199
|
+
const { execute } = useMutation({
|
|
200
|
+
queryFn: (data) => ContactService.update(data),
|
|
201
|
+
queryKeysToInvalidate: { contactDetail: () => true },
|
|
202
|
+
})
|
|
203
|
+
// After execute succeeds, contactDetail is invalidated
|
|
204
|
+
// Next useQuery('contactDetail') refetches fresh data
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Composable architecture
|
|
208
|
+
|
|
209
|
+
Each composable in vue-core-api-utils is built from:
|
|
210
|
+
|
|
211
|
+
1. **TanStack Query composable** — `useQuery`, `useInfiniteQuery`, `useMutation` from @tanstack/vue-query
|
|
212
|
+
2. **AsyncResult wrapper** — Result from neverthrow with loading state
|
|
213
|
+
3. **Type-safe parameters** — ProjectQueryKeys and error codes from your domain
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
// High-level (what you use)
|
|
217
|
+
const { result, isLoading, refetch } = useQuery('contactDetail', {
|
|
218
|
+
params: computed(() => ({ uuid })),
|
|
219
|
+
queryFn: () => ContactService.getDetail(uuid),
|
|
220
|
+
staleTime: 5 * 60 * 1000,
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
// Under the hood:
|
|
224
|
+
// 1. TanStack Query manages the fetch lifecycle
|
|
225
|
+
const query = useQueryRaw(queryKey, queryFn, { staleTime })
|
|
226
|
+
|
|
227
|
+
// 2. Wrap query state in AsyncResult
|
|
228
|
+
const result = computed(() => {
|
|
229
|
+
if (query.isLoading.value) return new AsyncResultLoading()
|
|
230
|
+
if (query.isError.value) return new AsyncResultErr(query.error.value)
|
|
231
|
+
return new AsyncResultOk(query.data.value)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// 3. Expose typed composable
|
|
235
|
+
return { result, isLoading: query.isLoading, refetch: query.refetch }
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The composables handle this composition. You just use `result.value.match()`.
|
|
239
|
+
|
|
240
|
+
## Error handling strategy
|
|
241
|
+
|
|
242
|
+
Errors are typed and structured using `neverthrow`:
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
// Error type definition
|
|
246
|
+
interface ApiExpectedError {
|
|
247
|
+
errors: Array<{
|
|
248
|
+
code: string
|
|
249
|
+
message: string
|
|
250
|
+
details?: unknown
|
|
251
|
+
}>
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
type ApiError = ApiExpectedError | ApiUnexpectedError
|
|
255
|
+
|
|
256
|
+
// In AsyncResult
|
|
257
|
+
const result = new AsyncResultErr(apiError)
|
|
258
|
+
|
|
259
|
+
result.match({
|
|
260
|
+
ok: (data) => {}, // not executed
|
|
261
|
+
err: (error) => {
|
|
262
|
+
// error is ApiError
|
|
263
|
+
if (error instanceof ApiExpectedError) {
|
|
264
|
+
// Handle known API errors
|
|
265
|
+
const codes = error.errors.map(e => e.code)
|
|
266
|
+
} else {
|
|
267
|
+
// Handle network/parsing errors
|
|
268
|
+
console.error(error.message)
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
})
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Error types are defined at library initialization via the generic `TErrorCode`. This ensures type-safe error handling across queries and mutations.
|
|
275
|
+
|
|
276
|
+
## Common Mistakes
|
|
277
|
+
|
|
278
|
+
### CRITICAL: Confuse Result (neverthrow) with AsyncResult; treat ok/err as boolean
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
// ❌ Wrong: neverthrow Result is not AsyncResult
|
|
282
|
+
const result = new Result(contact, null) // This is not how neverthrow works
|
|
283
|
+
if (result.ok) { // `.ok` doesn't exist
|
|
284
|
+
console.log(result.value)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Or even worse: treating AsyncResult like a boolean
|
|
288
|
+
const { result } = useQuery(...)
|
|
289
|
+
if (result.value) {
|
|
290
|
+
// This is always true; result is always defined (Loading | Ok | Err)
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
// ✅ Correct: AsyncResult requires exhaustive pattern matching
|
|
296
|
+
const { result } = useQuery('contactDetail', {
|
|
297
|
+
queryFn: () => ContactService.getDetail(uuid),
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
result.value.match({
|
|
301
|
+
loading: () => showSpinner(),
|
|
302
|
+
ok: (contact) => showContact(contact),
|
|
303
|
+
err: (error) => showError(error),
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
// Or use type guards
|
|
307
|
+
if (result.value.isOk()) {
|
|
308
|
+
console.log(result.value.getValue())
|
|
309
|
+
} else if (result.value.isErr()) {
|
|
310
|
+
console.log(result.value.getError())
|
|
311
|
+
} else {
|
|
312
|
+
showSpinner()
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
AsyncResult requires explicit handling of all three states. The type system won't let you skip a state.
|
|
317
|
+
|
|
318
|
+
Source: `docs/packages/api-utils/pages/concepts/result-types.md`
|
|
319
|
+
|
|
320
|
+
### MEDIUM: Misunderstand staleTime; think data refreshes automatically after staleTime
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
// ❌ Wrong: assuming staleTime auto-refetches
|
|
324
|
+
const { result } = useQuery('contactDetail', {
|
|
325
|
+
queryFn: () => ContactService.getDetail(uuid),
|
|
326
|
+
staleTime: 5 * 60 * 1000, // Not an auto-refresh interval
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
// At T=5m, data doesn't automatically refetch.
|
|
330
|
+
// It's just marked stale. Refetch happens on next interaction
|
|
331
|
+
// (component mount, user action, mutation invalidation)
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
```typescript
|
|
335
|
+
// ✅ Correct: staleTime is a grace period, not an interval
|
|
336
|
+
const { result, refetch } = useQuery('contactDetail', {
|
|
337
|
+
queryFn: () => ContactService.getDetail(uuid),
|
|
338
|
+
staleTime: 5 * 60 * 1000,
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
// Data is fresh for 5 minutes (instant returns)
|
|
342
|
+
// After 5 minutes, next access triggers refetch
|
|
343
|
+
// For auto-refresh, use refetch() in a watchEffect or timer
|
|
344
|
+
|
|
345
|
+
watchEffect(async () => {
|
|
346
|
+
// Refetch every 10 seconds
|
|
347
|
+
const interval = setInterval(() => refetch(), 10 * 1000)
|
|
348
|
+
onCleanup(() => clearInterval(interval))
|
|
349
|
+
})
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Stale time is not an auto-refresh interval. It's the duration the cache is considered fresh without refetching. Refetch happens on next access or when explicitly triggered.
|
|
353
|
+
|
|
354
|
+
### HIGH: Misunderstand gcTime and cache eviction; assume cache persists forever
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
// ❌ Wrong: assuming cache is permanent
|
|
358
|
+
const { result } = useQuery('contactDetail', {
|
|
359
|
+
queryFn: () => ContactService.getDetail(uuid),
|
|
360
|
+
gcTime: 5 * 60 * 1000, // Default is 5 minutes
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
// If component unmounts and user is gone for > 5 minutes,
|
|
364
|
+
// Next access refetches fresh (cache is evicted)
|
|
365
|
+
// This is correct behavior, but if you expected cached data...
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
// ✅ Correct: increase gcTime if you want longer-lived cache
|
|
370
|
+
const { result } = useQuery('contactDetail', {
|
|
371
|
+
queryFn: () => ContactService.getDetail(uuid),
|
|
372
|
+
staleTime: 5 * 60 * 1000, // Fresh for 5 minutes
|
|
373
|
+
gcTime: 60 * 60 * 1000, // Keep in memory for 1 hour
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
// After 5 minutes (stale) but before 1 hour (gc),
|
|
377
|
+
// Returning cached data (but trigger refetch automatically)
|
|
378
|
+
// After 1 hour, cache is deleted; next access refetches fresh
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
gcTime controls cache eviction. Longer gcTime = cache lives longer. Longer staleTime = more queries use cache without refetching. Both are configurable defaults in the plugin config.
|
|
382
|
+
|
|
383
|
+
Source: `packages/web/api-utils/src/config/config.ts`
|
|
384
|
+
|
|
385
|
+
### MEDIUM: Forget that QueryClient is shared; one query invalidation affects all components
|
|
386
|
+
|
|
387
|
+
```typescript
|
|
388
|
+
// ❌ Wrong: not realizing cache is global
|
|
389
|
+
const { execute } = useMutation({
|
|
390
|
+
queryFn: () => ContactService.update(data),
|
|
391
|
+
queryKeysToInvalidate: { contactDetail: () => true },
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
// Component A: detail view
|
|
395
|
+
// Component B: list view (also uses contactDetail)
|
|
396
|
+
// Even though A only updated one contact,
|
|
397
|
+
// B's cache is invalidated too (because same query key)
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
```typescript
|
|
401
|
+
// ✅ Correct: QueryClient is intentionally shared
|
|
402
|
+
export function useContactMutation() {
|
|
403
|
+
const { execute } = useMutation({
|
|
404
|
+
queryFn: () => ContactService.update(data),
|
|
405
|
+
queryKeysToInvalidate: {
|
|
406
|
+
// Invalidates all components using this key
|
|
407
|
+
contactDetail: () => true,
|
|
408
|
+
// Also invalidate lists that show this contact
|
|
409
|
+
contactList: () => true,
|
|
410
|
+
},
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
return { execute }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// When A updates a contact, both A and B refetch.
|
|
417
|
+
// This is the intended design: shared cache across app.
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
QueryClient is application-wide (singleton). Invalidating a query invalidates for all components using that key. This is a feature: synchronized cache across the app.
|
|
421
|
+
|
|
422
|
+
Source: `packages/web/api-utils/src/utils/query-client/queryClient.ts`
|
|
423
|
+
|
|
424
|
+
## Integration pattern
|
|
425
|
+
|
|
426
|
+
The full integration:
|
|
427
|
+
|
|
428
|
+
```
|
|
429
|
+
User interaction
|
|
430
|
+
↓
|
|
431
|
+
useQuery/useMutation composable
|
|
432
|
+
↓
|
|
433
|
+
@tanstack/vue-query (fetch + cache mgmt)
|
|
434
|
+
↓
|
|
435
|
+
Promise from queryFn
|
|
436
|
+
↓
|
|
437
|
+
neverthrow Result handling
|
|
438
|
+
↓
|
|
439
|
+
AsyncResult (Loading | Ok | Err)
|
|
440
|
+
↓
|
|
441
|
+
Vue computed ref (reactive)
|
|
442
|
+
↓
|
|
443
|
+
Template pattern matching with result.value.match()
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Each layer adds value:
|
|
447
|
+
- **User interaction** triggers the flow
|
|
448
|
+
- **Composable** provides type safety (ProjectQueryKeys)
|
|
449
|
+
- **TanStack Query** handles caching, refetching, lifecycle
|
|
450
|
+
- **neverthrow** enforces error handling at compile time
|
|
451
|
+
- **AsyncResult** makes state explicit in templates
|
|
452
|
+
- **Vue reactivity** keeps UI synchronized
|
|
453
|
+
|
|
454
|
+
Understanding this stack helps you use each piece correctly.
|
|
455
|
+
|
|
456
|
+
## See Also
|
|
457
|
+
|
|
458
|
+
- [AsyncResult Handling](../asyncresult-handling/SKILL.md) — Deep dive into pattern matching and type guards
|
|
459
|
+
- [Writing Queries](../writing-queries/SKILL.md) — Applying staleTime and refetch in real queries
|
|
460
|
+
- [Writing Mutations](../writing-mutations/SKILL.md) — How mutations invalidate cache
|
|
461
|
+
- [Cache Management](../cache-management/SKILL.md) — Manual cache operations behind the scenes
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: getting-started
|
|
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.
|
|
5
|
+
type: lifecycle
|
|
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
|
+
---
|
|
13
|
+
|
|
14
|
+
# @wisemen/vue-core-api-utils — Getting Started
|
|
15
|
+
|
|
16
|
+
Get `@wisemen/vue-core-api-utils` installed, your Vue Query plugin initialized, query keys defined, and typed composables created.
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
### 1. Install the package
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pnpm install @wisemen/vue-core-api-utils @tanstack/vue-query neverthrow vue
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### 2. Define your query keys
|
|
27
|
+
|
|
28
|
+
Create a TypeScript interface that maps query keys to their response types and parameters:
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
// src/types/queryKey.type.ts
|
|
32
|
+
|
|
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 }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Every key must have both `entity` (response type) and `params` (required parameters).
|
|
55
|
+
|
|
56
|
+
### 3. Initialize the plugin in your main.ts
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
// main.ts
|
|
60
|
+
|
|
61
|
+
import { createApp } from 'vue'
|
|
62
|
+
import { apiUtilsPlugin } from '@wisemen/vue-core-api-utils'
|
|
63
|
+
import App from './App.vue'
|
|
64
|
+
|
|
65
|
+
const app = createApp(App)
|
|
66
|
+
|
|
67
|
+
app.use(apiUtilsPlugin({
|
|
68
|
+
defaultOptions: {
|
|
69
|
+
queries: {
|
|
70
|
+
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
71
|
+
retry: 1,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
}))
|
|
75
|
+
|
|
76
|
+
app.mount('#app')
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The `apiUtilsPlugin` function creates a QueryClient with your config and handles @tanstack/vue-query setup internally.
|
|
80
|
+
|
|
81
|
+
### 4. Create your API composables
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
// src/api/index.ts
|
|
85
|
+
|
|
86
|
+
import type {
|
|
87
|
+
ApiResult as ApiUtilsApiResult,
|
|
88
|
+
KeysetPaginationResult as ApiUtilsKeysetPaginationResult,
|
|
89
|
+
OffsetPaginationResult as ApiUtilsOffsetPaginationResult,
|
|
90
|
+
} 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,
|
|
101
|
+
useMutation,
|
|
102
|
+
useOffsetInfiniteQuery,
|
|
103
|
+
useQuery,
|
|
104
|
+
usePrefetchKeysetInfiniteQuery,
|
|
105
|
+
usePrefetchOffsetInfiniteQuery,
|
|
106
|
+
usePrefetchQuery,
|
|
107
|
+
useQueryClient,
|
|
108
|
+
} = createApiUtils<ProjectQueryKeys, ERROR_KEYS>()
|
|
109
|
+
|
|
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>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Core Patterns
|
|
117
|
+
|
|
118
|
+
### Create a detail query composable
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
// src/composables/useContactDetail.ts
|
|
122
|
+
|
|
123
|
+
import { computed } from 'vue'
|
|
124
|
+
import { useQuery } from '@/api'
|
|
125
|
+
import { ContactService } from '@/services'
|
|
126
|
+
|
|
127
|
+
export function useContactDetail(contactUuid: string) {
|
|
128
|
+
return useQuery('contactDetail', {
|
|
129
|
+
params: { contactUuid: computed(() => contactUuid) },
|
|
130
|
+
queryFn: () => ContactService.getByUuid(contactUuid),
|
|
131
|
+
staleTime: 1000 * 60 * 5,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Parameters must be computed refs so the query watches changes and refetches automatically.
|
|
137
|
+
|
|
138
|
+
### Create a mutation composable
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
// src/composables/useCreateContact.ts
|
|
142
|
+
|
|
143
|
+
import { useMutation } from '@/api'
|
|
144
|
+
import { ContactService } from '@/services'
|
|
145
|
+
|
|
146
|
+
export function useCreateContact() {
|
|
147
|
+
return useMutation({
|
|
148
|
+
queryFn: async (options: { body: ContactCreateForm }) => {
|
|
149
|
+
return await ContactService.create(options.body)
|
|
150
|
+
},
|
|
151
|
+
queryKeysToInvalidate: {
|
|
152
|
+
contactList: {}, // Invalidate all contactList queries after success
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Every mutation should list which queries to invalidate via `queryKeysToInvalidate`.
|
|
159
|
+
|
|
160
|
+
### Use composables in components
|
|
161
|
+
|
|
162
|
+
```vue
|
|
163
|
+
<script setup lang="ts">
|
|
164
|
+
import { useContactDetail } from '@/composables'
|
|
165
|
+
|
|
166
|
+
const props = defineProps<{ contactUuid: string }>()
|
|
167
|
+
const { result, refetch } = useContactDetail(props.contactUuid)
|
|
168
|
+
</script>
|
|
169
|
+
|
|
170
|
+
<template>
|
|
171
|
+
<div>
|
|
172
|
+
<div v-if="result.isLoading()">Loading...</div>
|
|
173
|
+
<div v-else-if="result.isOk()">
|
|
174
|
+
Name: {{ result.getValue().name }}
|
|
175
|
+
</div>
|
|
176
|
+
<div v-else-if="result.isErr()">
|
|
177
|
+
Error: {{ result.getError().detail }}
|
|
178
|
+
</div>
|
|
179
|
+
<button @click="refetch">Retry</button>
|
|
180
|
+
</div>
|
|
181
|
+
</template>
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
All queries and mutations return `AsyncResult` with three states: loading, ok, and err.
|
|
185
|
+
|
|
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
|
+
## You're all set!
|
|
241
|
+
|
|
242
|
+
You now have:
|
|
243
|
+
- ✅ Plugin initialized with Vue Query
|
|
244
|
+
- ✅ Query keys defined with types
|
|
245
|
+
- ✅ API composables created
|
|
246
|
+
- ✅ Error codes enumerated
|
|
247
|
+
|
|
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.
|