@wisemen/vue-core-api-utils 1.0.1 → 1.1.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.
@@ -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.