@gallopsystems/agent-skills 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +137 -0
  2. package/package.json +26 -0
  3. package/plugins/doctl/.claude-plugin/plugin.json +8 -0
  4. package/plugins/doctl/skills/doctl/SKILL.md +93 -0
  5. package/plugins/kysely-postgres/.claude-plugin/plugin.json +8 -0
  6. package/plugins/kysely-postgres/skills/kysely-postgres/SKILL.md +1101 -0
  7. package/plugins/kysely-postgres/skills/kysely-postgres/references/aggregations.ts +167 -0
  8. package/plugins/kysely-postgres/skills/kysely-postgres/references/ctes.ts +165 -0
  9. package/plugins/kysely-postgres/skills/kysely-postgres/references/expressions.ts +272 -0
  10. package/plugins/kysely-postgres/skills/kysely-postgres/references/joins.ts +206 -0
  11. package/plugins/kysely-postgres/skills/kysely-postgres/references/json-arrays.ts +398 -0
  12. package/plugins/kysely-postgres/skills/kysely-postgres/references/mutations.ts +199 -0
  13. package/plugins/kysely-postgres/skills/kysely-postgres/references/orderby-pagination.ts +117 -0
  14. package/plugins/kysely-postgres/skills/kysely-postgres/references/relations.ts +176 -0
  15. package/plugins/kysely-postgres/skills/kysely-postgres/references/select-where.ts +146 -0
  16. package/plugins/linear/.claude-plugin/plugin.json +8 -0
  17. package/plugins/linear/skills/linear/SKILL.md +1040 -0
  18. package/plugins/linear/skills/linear/bin/linear.mjs +1228 -0
  19. package/plugins/linear/skills/linear/tech-stack.md +273 -0
  20. package/plugins/nitro-testing/.claude-plugin/plugin.json +8 -0
  21. package/plugins/nitro-testing/skills/nitro-testing/SKILL.md +497 -0
  22. package/plugins/nitro-testing/skills/nitro-testing/async-testing.md +270 -0
  23. package/plugins/nitro-testing/skills/nitro-testing/ci-setup.md +226 -0
  24. package/plugins/nitro-testing/skills/nitro-testing/examples/global-setup.ts +90 -0
  25. package/plugins/nitro-testing/skills/nitro-testing/examples/handler.test.ts +167 -0
  26. package/plugins/nitro-testing/skills/nitro-testing/examples/setup.ts +29 -0
  27. package/plugins/nitro-testing/skills/nitro-testing/examples/test-utils-index.ts +297 -0
  28. package/plugins/nitro-testing/skills/nitro-testing/examples/vitest.config.ts +42 -0
  29. package/plugins/nitro-testing/skills/nitro-testing/factories.md +278 -0
  30. package/plugins/nitro-testing/skills/nitro-testing/frontend-testing.md +512 -0
  31. package/plugins/nitro-testing/skills/nitro-testing/test-utils.md +262 -0
  32. package/plugins/nitro-testing/skills/nitro-testing/transaction-rollback.md +183 -0
  33. package/plugins/nitro-testing/skills/nitro-testing/vitest-config.md +236 -0
  34. package/plugins/nuxt-nitro-api/.claude-plugin/plugin.json +8 -0
  35. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/SKILL.md +260 -0
  36. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/auth-patterns.md +228 -0
  37. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/composables-utils.md +174 -0
  38. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/deep-linking.md +190 -0
  39. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/auth-middleware.ts +32 -0
  40. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/auth-utils.ts +51 -0
  41. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/deep-link-page.vue +61 -0
  42. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/service-util.ts +63 -0
  43. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/sse-endpoint.ts +59 -0
  44. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/validation-endpoint.ts +38 -0
  45. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/fetch-patterns.md +178 -0
  46. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/nitro-tasks.md +243 -0
  47. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/page-structure.md +162 -0
  48. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/server-services.md +238 -0
  49. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/sse.md +221 -0
  50. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/ssr-client.md +166 -0
  51. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/validation.md +131 -0
  52. package/scripts/link-skills.mjs +252 -0
@@ -0,0 +1,190 @@
1
+ # Deep Linking (URL Params Sync)
2
+
3
+ > **Example:** [deep-link-page.vue](./examples/deep-link-page.vue)
4
+
5
+ Make filters bookmarkable/shareable by syncing them with URL query params.
6
+
7
+ ## Pattern 1: `useRouteQuery` (Recommended)
8
+
9
+ From `@vueuse/router` (install: `npm install @vueuse/router`):
10
+
11
+ > **Important:** Pass Nuxt's route/router composables explicitly.
12
+
13
+ ```typescript
14
+ import { useRouteQuery } from "@vueuse/router";
15
+
16
+ // Get Nuxt composables
17
+ const route = useRoute();
18
+ const router = useRouter();
19
+
20
+ // Each filter synced with URL
21
+ const search = useRouteQuery("search", "", { route, router });
22
+ const status = useRouteQuery("status", "all", { route, router });
23
+ const page = useRouteQuery("page", "1", {
24
+ route, router,
25
+ transform: Number, // Parse as number
26
+ });
27
+
28
+ // Debounce search to avoid URL thrashing
29
+ const debouncedSearch = refDebounced(search, 300);
30
+
31
+ // Build query for useFetch - exclude empty/default values
32
+ const queryParams = computed(() => ({
33
+ ...(debouncedSearch.value ? { search: debouncedSearch.value } : {}),
34
+ ...(status.value !== "all" ? { status: status.value } : {}),
35
+ offset: (page.value - 1) * 20,
36
+ limit: 20,
37
+ }));
38
+
39
+ // Auto-refetches when params change
40
+ const { data, status: fetchStatus } = await useFetch("/api/items", {
41
+ query: queryParams,
42
+ });
43
+
44
+ // Reset pagination when filters change
45
+ watch([debouncedSearch, status], () => {
46
+ page.value = 1;
47
+ });
48
+ ```
49
+
50
+ **Result:** URL like `/items?search=hello&status=active&page=2` loads with filters pre-applied.
51
+
52
+ ## Pattern 2: Manual with useRoute/useRouter
53
+
54
+ For more control:
55
+
56
+ ```typescript
57
+ const route = useRoute();
58
+ const router = useRouter();
59
+
60
+ // Initialize from URL (works during SSR)
61
+ const search = ref((route.query.search as string) || "");
62
+ const category = ref((route.query.category as string) || "");
63
+
64
+ // Debounced URL update
65
+ const updateUrl = useDebounceFn(() => {
66
+ router.push({
67
+ query: {
68
+ ...route.query, // Preserve other params
69
+ ...(search.value ? { search: search.value } : {}),
70
+ ...(category.value ? { category: category.value } : {}),
71
+ },
72
+ });
73
+ }, 300);
74
+
75
+ watch([search, category], updateUrl);
76
+
77
+ // useFetch with same refs
78
+ const { data } = await useFetch("/api/items", {
79
+ query: computed(() => ({
80
+ ...(search.value ? { search: search.value } : {}),
81
+ ...(category.value ? { category: category.value } : {}),
82
+ })),
83
+ });
84
+ ```
85
+
86
+ ## Pattern 3: `useUrlSearchParams` (Low-Level)
87
+
88
+ Direct URL manipulation via VueUse:
89
+
90
+ ```typescript
91
+ const urlParams = useUrlSearchParams("history");
92
+
93
+ // Read
94
+ const search = urlParams["search"] as string || "";
95
+
96
+ // Write
97
+ urlParams["search"] = "new value";
98
+
99
+ // Delete (must use delete, not null)
100
+ delete urlParams["search"];
101
+ ```
102
+
103
+ **Best for:** Complex serialization, arrays, nested objects.
104
+
105
+ ## Array Values in URL
106
+
107
+ For multi-select filters:
108
+
109
+ ```typescript
110
+ // Serialize array to comma-separated
111
+ const selectedStatuses = useRouteQuery("status", "", {
112
+ route, router,
113
+ transform: (val) => val ? val.split(",") : [],
114
+ });
115
+
116
+ // Manual approach
117
+ const statuses = ref<string[]>([]);
118
+ watch(statuses, (val) => {
119
+ urlParams["status"] = val.length ? val.join(",") : undefined;
120
+ });
121
+ ```
122
+
123
+ ## Complete Example
124
+
125
+ ```typescript
126
+ import { useRouteQuery } from "@vueuse/router";
127
+
128
+ const route = useRoute();
129
+ const router = useRouter();
130
+
131
+ // All filters bound to URL
132
+ const search = useRouteQuery("q", "", { route, router });
133
+ const status = useRouteQuery("status", "all", { route, router });
134
+ const sortBy = useRouteQuery("sort", "created_at", { route, router });
135
+ const sortOrder = useRouteQuery("order", "desc", { route, router });
136
+ const page = useRouteQuery("page", "1", { route, router, transform: Number });
137
+ const perPage = useRouteQuery("limit", "20", { route, router, transform: Number });
138
+
139
+ // Debounce search
140
+ const debouncedSearch = refDebounced(search, 300);
141
+
142
+ // Query params for API
143
+ const queryParams = computed(() => ({
144
+ ...(debouncedSearch.value ? { search: debouncedSearch.value } : {}),
145
+ ...(status.value !== "all" ? { status: status.value } : {}),
146
+ sort_by: sortBy.value,
147
+ sort_order: sortOrder.value,
148
+ offset: (page.value - 1) * perPage.value,
149
+ limit: perPage.value,
150
+ }));
151
+
152
+ // Fetch with reactive params
153
+ const { data, refresh } = await useFetch("/api/items", {
154
+ query: queryParams,
155
+ });
156
+
157
+ // Reset pagination on filter change
158
+ watch([debouncedSearch, status, sortBy, sortOrder], () => {
159
+ page.value = 1;
160
+ });
161
+
162
+ // Pagination helpers
163
+ const totalPages = computed(() =>
164
+ Math.ceil((data.value?.total || 0) / perPage.value)
165
+ );
166
+ ```
167
+
168
+ ## SSR Considerations
169
+
170
+ 1. **`useRouteQuery` is SSR-safe** - reads from route during SSR
171
+ 2. **`useRoute()` works during SSR** - can initialize from URL
172
+ 3. **`useUrlSearchParams` is client-only** - guard with `import.meta.client`
173
+ 4. **`router.push()` works during SSR** - but avoid at top-level setup
174
+
175
+ ## Which Pattern?
176
+
177
+ | Pattern | Best For | SSR-Safe |
178
+ |---------|----------|----------|
179
+ | `useRouteQuery` | Most cases - bidirectional sync | Yes |
180
+ | `useRoute` + `router.push` | Custom serialization | Yes |
181
+ | `useUrlSearchParams` | Direct param manipulation | No |
182
+
183
+ ## Key Gotchas
184
+
185
+ 1. **Debounce search inputs** - otherwise URL updates every keystroke
186
+ 2. **Reset pagination on filter change** - avoid empty page 5
187
+ 3. **Exclude default values** - cleaner URLs
188
+ 4. **Use `transform` for numbers** - URL params are strings
189
+ 5. **Arrays need serialization** - comma-separated or custom
190
+ 6. **Pass Nuxt composables** - VueUse router utils need route/router
@@ -0,0 +1,32 @@
1
+ // server/middleware/auth.ts
2
+ // Server middleware for protecting API routes
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ // Skip auth for public routes
6
+ const publicPaths = ["/api/auth", "/api/_auth", "/api/public"];
7
+ if (publicPaths.some((path) => event.path.startsWith(path))) {
8
+ return;
9
+ }
10
+
11
+ // Require auth for all /api/* routes
12
+ if (event.path.startsWith("/api")) {
13
+ const session = await getUserSession(event);
14
+
15
+ if (!session?.user) {
16
+ throw createError({
17
+ statusCode: 401,
18
+ statusMessage: "Unauthorized",
19
+ });
20
+ }
21
+
22
+ // Role-based restrictions
23
+ if (event.path.startsWith("/api/admin")) {
24
+ if (session.user.role !== "admin") {
25
+ throw createError({
26
+ statusCode: 403,
27
+ statusMessage: "Forbidden - Admin access required",
28
+ });
29
+ }
30
+ }
31
+ }
32
+ });
@@ -0,0 +1,51 @@
1
+ // server/utils/auth.ts
2
+ // Reusable auth helpers (auto-imported in /server)
3
+ import type { H3Event } from "h3";
4
+
5
+ // Type for user in session
6
+ export interface SessionUser {
7
+ id: number;
8
+ email: string;
9
+ name: string;
10
+ role: "admin" | "user";
11
+ }
12
+
13
+ /**
14
+ * Get authenticated user or throw 401
15
+ */
16
+ export async function getAuthenticatedUser(event: H3Event): Promise<SessionUser> {
17
+ const session = await getUserSession(event);
18
+ if (!session?.user) {
19
+ throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
20
+ }
21
+ return session.user as SessionUser;
22
+ }
23
+
24
+ /**
25
+ * Require specific role(s) or throw 403
26
+ */
27
+ export async function requireRole(
28
+ event: H3Event,
29
+ allowedRoles: SessionUser["role"][]
30
+ ): Promise<SessionUser> {
31
+ const user = await getAuthenticatedUser(event);
32
+ if (!allowedRoles.includes(user.role)) {
33
+ throw createError({
34
+ statusCode: 403,
35
+ statusMessage: `Forbidden - Requires one of: ${allowedRoles.join(", ")}`,
36
+ });
37
+ }
38
+ return user;
39
+ }
40
+
41
+ /**
42
+ * Shorthand for requiring admin role
43
+ */
44
+ export async function requireAdmin(event: H3Event): Promise<SessionUser> {
45
+ return requireRole(event, ["admin"]);
46
+ }
47
+
48
+ // Usage in API handlers:
49
+ // const user = await getAuthenticatedUser(event);
50
+ // const admin = await requireAdmin(event);
51
+ // const manager = await requireRole(event, ["admin", "manager"]);
@@ -0,0 +1,61 @@
1
+ <script setup lang="ts">
2
+ // Deep Linking: URL params → Filter state → useFetch query
3
+ // URL: /items?search=hello&status=active&page=2
4
+
5
+ import { useRouteQuery } from "@vueuse/router";
6
+
7
+ // Get Nuxt composables (required for @vueuse/router)
8
+ const route = useRoute();
9
+ const router = useRouter();
10
+
11
+ // Each param synced with URL - pass route/router explicitly
12
+ const search = useRouteQuery("search", "", { route, router });
13
+ const status = useRouteQuery("status", "all", { route, router });
14
+ const page = useRouteQuery("page", "1", {
15
+ route,
16
+ router,
17
+ transform: Number,
18
+ });
19
+
20
+ // Debounce search to avoid thrashing
21
+ const debouncedSearch = refDebounced(search, 300);
22
+
23
+ // Build query - exclude empty/default values for clean URLs
24
+ const queryParams = computed(() => ({
25
+ ...(debouncedSearch.value ? { search: debouncedSearch.value } : {}),
26
+ ...(status.value !== "all" ? { status: status.value } : {}),
27
+ offset: (page.value - 1) * 20,
28
+ limit: 20,
29
+ }));
30
+
31
+ // useFetch with reactive query - auto-refetches on change
32
+ const { data, status: fetchStatus } = await useFetch("/api/items", {
33
+ query: queryParams,
34
+ });
35
+
36
+ // Reset pagination when filters change
37
+ watch([debouncedSearch, status], () => {
38
+ page.value = 1;
39
+ });
40
+ </script>
41
+
42
+ <template>
43
+ <div>
44
+ <h1>Items</h1>
45
+
46
+ <div class="filters">
47
+ <input v-model="search" placeholder="Search..." />
48
+ <select v-model="status">
49
+ <option value="all">All</option>
50
+ <option value="active">Active</option>
51
+ <option value="inactive">Inactive</option>
52
+ </select>
53
+ <input v-model.number="page" type="number" min="1" />
54
+ </div>
55
+
56
+ <div v-if="fetchStatus === 'pending'">Loading...</div>
57
+ <div v-else-if="data">
58
+ <pre>{{ data }}</pre>
59
+ </div>
60
+ </div>
61
+ </template>
@@ -0,0 +1,63 @@
1
+ // server/utils/stripe.ts
2
+ // Server-side service integration pattern using Stripe
3
+
4
+ import Stripe from "stripe";
5
+
6
+ // Initialize at module level with runtime config
7
+ const config = useRuntimeConfig();
8
+ const stripe = new Stripe(config.stripe.secretKey);
9
+
10
+ // Define typed methods
11
+ interface CreatePaymentIntentOptions {
12
+ amount: number;
13
+ currency: string;
14
+ metadata?: Record<string, string>;
15
+ }
16
+
17
+ async function createPaymentIntent(options: CreatePaymentIntentOptions) {
18
+ try {
19
+ return await stripe.paymentIntents.create({
20
+ amount: options.amount,
21
+ currency: options.currency,
22
+ metadata: options.metadata,
23
+ });
24
+ } catch (error: any) {
25
+ // Transform SDK errors to HTTP errors
26
+ throw createError({
27
+ statusCode: error.statusCode || 500,
28
+ message: `Stripe error: ${error.message}`,
29
+ });
30
+ }
31
+ }
32
+
33
+ async function getCustomer(customerId: string) {
34
+ try {
35
+ return await stripe.customers.retrieve(customerId);
36
+ } catch (error: any) {
37
+ if (error.code === "resource_missing") {
38
+ return null;
39
+ }
40
+ throw createError({
41
+ statusCode: error.statusCode || 500,
42
+ message: `Stripe error: ${error.message}`,
43
+ });
44
+ }
45
+ }
46
+
47
+ async function createCustomer(email: string, name: string) {
48
+ return await stripe.customers.create({ email, name });
49
+ }
50
+
51
+ // Export as use*() function
52
+ export function useStripe() {
53
+ return {
54
+ createPaymentIntent,
55
+ getCustomer,
56
+ createCustomer,
57
+ client: stripe, // Expose for advanced usage
58
+ };
59
+ }
60
+
61
+ // Usage in API handler:
62
+ // const { createPaymentIntent } = useStripe();
63
+ // const intent = await createPaymentIntent({ amount: 1000, currency: "usd" });
@@ -0,0 +1,59 @@
1
+ // server/api/stream/[id].get.ts
2
+ // Server-Sent Events endpoint for real-time streaming
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const { id } = getRouterParams(event);
6
+
7
+ // Create the event stream
8
+ const eventStream = createEventStream(event);
9
+
10
+ let done = false;
11
+
12
+ // Handle client disconnect
13
+ eventStream.onClosed(async () => {
14
+ console.log(`Stream ${id}: Client disconnected`);
15
+ done = true;
16
+ await eventStream.close();
17
+ });
18
+
19
+ // Heartbeat to keep connection alive
20
+ const heartbeat = setInterval(async () => {
21
+ if (!done) {
22
+ await eventStream.push(JSON.stringify({ type: "heartbeat" }));
23
+ }
24
+ }, 30000);
25
+
26
+ // Stream data
27
+ (async () => {
28
+ try {
29
+ while (!done) {
30
+ // Get next chunk of data (your logic here)
31
+ const data = await getNextChunk(id);
32
+
33
+ if (data) {
34
+ await eventStream.push(JSON.stringify(data));
35
+
36
+ if (data.type === "done" || data.type === "error") {
37
+ done = true;
38
+ }
39
+ } else {
40
+ // No data yet, wait before checking again
41
+ await new Promise((r) => setTimeout(r, 1000));
42
+ }
43
+ }
44
+ } finally {
45
+ clearInterval(heartbeat);
46
+ await eventStream.close();
47
+ }
48
+ })();
49
+
50
+ return eventStream.send();
51
+ });
52
+
53
+ // Helper - replace with your data source
54
+ async function getNextChunk(id: string) {
55
+ // Example: fetch from Redis, database, or queue
56
+ // return { type: "chunk", text: "Hello" }
57
+ // return { type: "done" }
58
+ return null;
59
+ }
@@ -0,0 +1,38 @@
1
+ // server/api/validation/users.get.ts
2
+ // API endpoint with Zod query validation
3
+ import { z } from "zod";
4
+
5
+ const querySchema = z.object({
6
+ search: z.string().min(1).optional(),
7
+ page: z.coerce.number().int().positive().default(1),
8
+ limit: z.coerce.number().int().min(1).max(100).default(20),
9
+ status: z.enum(["active", "inactive", "all"]).default("all"),
10
+ });
11
+
12
+ export default defineEventHandler(async (event) => {
13
+ // h3 v2+ - pass schema directly (Standard Schema)
14
+ const query = await getValidatedQuery(event, querySchema);
15
+
16
+ // query is fully typed: { search?: string, page: number, limit: number, status: "active" | "inactive" | "all" }
17
+
18
+ // Use the validated params
19
+ const offset = (query.page - 1) * query.limit;
20
+
21
+ // Example: Build database query (pseudo-code)
22
+ // const users = await db.selectFrom("user")
23
+ // .where((eb) => {
24
+ // const filters = [];
25
+ // if (query.search) filters.push(eb("name", "ilike", `%${query.search}%`));
26
+ // if (query.status !== "all") filters.push(eb("status", "=", query.status));
27
+ // return eb.and(filters);
28
+ // })
29
+ // .limit(query.limit)
30
+ // .offset(offset)
31
+ // .execute();
32
+
33
+ return {
34
+ message: "Query validated successfully",
35
+ query,
36
+ pagination: { page: query.page, limit: query.limit, offset },
37
+ };
38
+ });
@@ -0,0 +1,178 @@
1
+ # Fetch Patterns
2
+
3
+ ## The Three Methods
4
+
5
+ | Method | SSR | When to Use |
6
+ |--------|-----|-------------|
7
+ | `useFetch` | Yes | Default for page data loading |
8
+ | `$fetch` | No | Event handlers (onClick, onSubmit) |
9
+ | `useAsyncData` + `$fetch` | Yes | Custom cache keys, combining fetches |
10
+
11
+ ## useFetch (Default Choice)
12
+
13
+ ```typescript
14
+ // Basic - types inferred from Nitro
15
+ const { data, status, refresh, error } = await useFetch("/api/users");
16
+
17
+ // With reactive query params - auto-refetches on change
18
+ const search = ref("");
19
+ const page = ref(1);
20
+ const { data } = await useFetch("/api/users", {
21
+ query: { search, page }, // Reactive refs
22
+ });
23
+
24
+ // Computed query for conditional params
25
+ const queryParams = computed(() => ({
26
+ ...(search.value ? { search: search.value } : {}),
27
+ offset: page.value * 20,
28
+ }));
29
+ const { data } = await useFetch("/api/users", {
30
+ query: queryParams,
31
+ });
32
+
33
+ // Dynamic URL with getter function
34
+ const userId = ref("123");
35
+ const { data } = await useFetch(() => `/api/users/${userId.value}`);
36
+
37
+ // Transform response before caching
38
+ const { data } = await useFetch("/api/users", {
39
+ transform: (response) => response.users.map(u => u.name),
40
+ });
41
+
42
+ // Reduce SSR payload size
43
+ const { data } = await useFetch("/api/users", {
44
+ pick: ["id", "name"], // Only these fields
45
+ });
46
+ ```
47
+
48
+ ### New Options (Nuxt 3.14+)
49
+
50
+ ```typescript
51
+ const { data } = await useFetch("/api/data", {
52
+ // Retry on failure
53
+ retry: 3,
54
+ retryDelay: 1000,
55
+
56
+ // Request deduplication
57
+ dedupe: "cancel", // Cancel previous (default)
58
+ // dedupe: "defer", // Wait for existing
59
+
60
+ // Built-in debounce
61
+ delay: 300, // Wait before making request
62
+ });
63
+ ```
64
+
65
+ ## Debounced Search Pattern
66
+
67
+ ```typescript
68
+ const search = ref("");
69
+ const debouncedSearch = refDebounced(search, 300); // Auto-imported
70
+
71
+ const { data } = await useFetch("/api/search", {
72
+ query: computed(() => ({
73
+ ...(debouncedSearch.value ? { q: debouncedSearch.value } : {}),
74
+ })),
75
+ });
76
+
77
+ // Reset pagination when filters change
78
+ watch([debouncedSearch, categoryFilter], () => {
79
+ page.value = 0;
80
+ });
81
+ ```
82
+
83
+ ## useAsyncData + $fetch
84
+
85
+ Use when you need:
86
+ 1. Custom cache key
87
+ 2. Combine multiple fetches
88
+ 3. Non-HTTP async operations
89
+
90
+ ```typescript
91
+ // Custom cache key
92
+ const { data } = await useAsyncData("my-key", () =>
93
+ $fetch("/api/users")
94
+ );
95
+
96
+ // Combining fetches
97
+ const { data } = await useAsyncData("combined", async () => {
98
+ const [users, roles] = await Promise.all([
99
+ $fetch("/api/users"),
100
+ $fetch("/api/roles"),
101
+ ]);
102
+ return { users, roles };
103
+ });
104
+ ```
105
+
106
+ ## $fetch (Client-Only)
107
+
108
+ Only use in event handlers - never at component top level:
109
+
110
+ ```typescript
111
+ const handleSubmit = async () => {
112
+ const result = await $fetch("/api/users", {
113
+ method: "POST",
114
+ body: { name: "Test" },
115
+ });
116
+ };
117
+
118
+ const handleDelete = async (id: number) => {
119
+ await $fetch(`/api/users/${id}`, { method: "DELETE" });
120
+ refresh(); // Refresh useFetch data
121
+ };
122
+ ```
123
+
124
+ ## Type Inference
125
+
126
+ Template literals preserve type inference (fixed late 2024):
127
+
128
+ ```typescript
129
+ const userId = "123"; // Type is "123" (literal)
130
+ const result = await $fetch(`/api/users/${userId}`);
131
+ // result typed from handler return type
132
+
133
+ // Generic string loses precision
134
+ const userId: string = "123"; // Type is string
135
+ const result = await $fetch(`/api/users/${userId}`);
136
+ // result is union of all matching routes
137
+ ```
138
+
139
+ ### Static route shadowing a dynamic sibling
140
+
141
+ Template-literal inference normally resolves a dynamic route fine. But adding a
142
+ **static** route under an existing `[param]` directory makes a template-literal
143
+ `$fetch` to the dynamic sibling ambiguous. E.g. adding
144
+ `server/api/invoices/summary.get.ts` next to `server/api/invoices/[id].get.ts`:
145
+ the typed-route map now matches both `/api/invoices/:id` and
146
+ `/api/invoices/summary`, so inference can't tell which `$fetch(\`/api/invoices/${id}\`)`
147
+ means — and typecheck fails on the existing callers.
148
+
149
+ Disambiguate by casting the URL to the specific route id (NOT the response
150
+ type — that still defeats response inference):
151
+
152
+ ```typescript
153
+ // Ambiguous after adding the static `/api/invoices/summary` route:
154
+ const invoice = await $fetch(`/api/invoices/${id}`);
155
+
156
+ // RIGHT - name the route id; the response stays inferred
157
+ const invoice = await $fetch(`/api/invoices/${id}` as "/api/invoices/:id");
158
+ ```
159
+
160
+ Expect this side effect whenever you add a static endpoint beside a `[param]`
161
+ one — you'll need to touch the existing dynamic-route callers.
162
+
163
+ **Never add manual types:**
164
+ ```typescript
165
+ // WRONG - defeats inference
166
+ const result = await $fetch<User>("/api/users/123");
167
+
168
+ // RIGHT - let Nitro infer
169
+ const result = await $fetch("/api/users/123");
170
+ ```
171
+
172
+ ## Common Mistakes
173
+
174
+ 1. **Using `$fetch` in `onMounted`** - Use `useFetch` instead
175
+ 2. **Manual watchers for refetch** - Query refs are auto-watched
176
+ 3. **Adding type params** - Types are inferred from Nitro
177
+ 4. **Using `watch` option for dynamic URLs** - Use getter function
178
+ 5. **Passing null/undefined in query** - Filter them out first