@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.
- package/README.md +137 -0
- package/package.json +26 -0
- package/plugins/doctl/.claude-plugin/plugin.json +8 -0
- package/plugins/doctl/skills/doctl/SKILL.md +93 -0
- package/plugins/kysely-postgres/.claude-plugin/plugin.json +8 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/SKILL.md +1101 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/aggregations.ts +167 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/ctes.ts +165 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/expressions.ts +272 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/joins.ts +206 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/json-arrays.ts +398 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/mutations.ts +199 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/orderby-pagination.ts +117 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/relations.ts +176 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/select-where.ts +146 -0
- package/plugins/linear/.claude-plugin/plugin.json +8 -0
- package/plugins/linear/skills/linear/SKILL.md +1040 -0
- package/plugins/linear/skills/linear/bin/linear.mjs +1228 -0
- package/plugins/linear/skills/linear/tech-stack.md +273 -0
- package/plugins/nitro-testing/.claude-plugin/plugin.json +8 -0
- package/plugins/nitro-testing/skills/nitro-testing/SKILL.md +497 -0
- package/plugins/nitro-testing/skills/nitro-testing/async-testing.md +270 -0
- package/plugins/nitro-testing/skills/nitro-testing/ci-setup.md +226 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/global-setup.ts +90 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/handler.test.ts +167 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/setup.ts +29 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/test-utils-index.ts +297 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/vitest.config.ts +42 -0
- package/plugins/nitro-testing/skills/nitro-testing/factories.md +278 -0
- package/plugins/nitro-testing/skills/nitro-testing/frontend-testing.md +512 -0
- package/plugins/nitro-testing/skills/nitro-testing/test-utils.md +262 -0
- package/plugins/nitro-testing/skills/nitro-testing/transaction-rollback.md +183 -0
- package/plugins/nitro-testing/skills/nitro-testing/vitest-config.md +236 -0
- package/plugins/nuxt-nitro-api/.claude-plugin/plugin.json +8 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/SKILL.md +260 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/auth-patterns.md +228 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/composables-utils.md +174 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/deep-linking.md +190 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/auth-middleware.ts +32 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/auth-utils.ts +51 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/deep-link-page.vue +61 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/service-util.ts +63 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/sse-endpoint.ts +59 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/validation-endpoint.ts +38 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/fetch-patterns.md +178 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/nitro-tasks.md +243 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/page-structure.md +162 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/server-services.md +238 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/sse.md +221 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/ssr-client.md +166 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/validation.md +131 -0
- package/scripts/link-skills.mjs +252 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# Nitro Tasks
|
|
2
|
+
|
|
3
|
+
Background jobs, scheduled tasks, and one-off operations.
|
|
4
|
+
|
|
5
|
+
## Enabling Tasks
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
// nuxt.config.ts
|
|
9
|
+
export default defineNuxtConfig({
|
|
10
|
+
nitro: {
|
|
11
|
+
experimental: {
|
|
12
|
+
tasks: true,
|
|
13
|
+
},
|
|
14
|
+
scheduledTasks: {
|
|
15
|
+
// Cron format: minute hour day month weekday
|
|
16
|
+
"*/5 * * * *": ["scheduled:cleanup"], // Every 5 minutes
|
|
17
|
+
"0 0 * * *": ["scheduled:daily-report"], // Daily at midnight
|
|
18
|
+
"0 8 * * 1": ["scheduled:weekly-digest"], // Mondays at 8am
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Defining Tasks
|
|
25
|
+
|
|
26
|
+
Tasks live in `server/tasks/`. Directory structure = task name with colons:
|
|
27
|
+
- `server/tasks/hello.ts` → `hello`
|
|
28
|
+
- `server/tasks/scheduled/cleanup.ts` → `scheduled:cleanup`
|
|
29
|
+
- `server/tasks/jobs/send-email.ts` → `jobs:send-email`
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
// server/tasks/jobs/send-email.ts
|
|
33
|
+
import { z } from "zod";
|
|
34
|
+
|
|
35
|
+
const PayloadSchema = z.object({
|
|
36
|
+
to: z.string().email(),
|
|
37
|
+
subject: z.string(),
|
|
38
|
+
body: z.string(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export default defineTask({
|
|
42
|
+
meta: {
|
|
43
|
+
name: "jobs:send-email",
|
|
44
|
+
description: "Send an email in the background",
|
|
45
|
+
},
|
|
46
|
+
async run({ payload }) {
|
|
47
|
+
const data = PayloadSchema.parse(payload);
|
|
48
|
+
await sendEmail(data);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
result: "success",
|
|
52
|
+
sentAt: new Date().toISOString(),
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Running Tasks
|
|
59
|
+
|
|
60
|
+
### 1. Programmatically with `runTask`
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
export default defineEventHandler(async (event) => {
|
|
64
|
+
const result = await runTask("jobs:send-email", {
|
|
65
|
+
payload: {
|
|
66
|
+
to: "user@example.com",
|
|
67
|
+
subject: "Hello",
|
|
68
|
+
body: "Welcome!",
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return { taskResult: result };
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 2. Fire-and-forget Pattern
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
// Don't wait for completion
|
|
80
|
+
runTask("jobs:send-email", {
|
|
81
|
+
payload: { to: "user@example.com", subject: "Hello", body: "Hi!" },
|
|
82
|
+
}).catch((error) => {
|
|
83
|
+
console.error("Task failed:", error);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return { message: "Email queued" };
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 3. Dev Server API (development only)
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
GET /_nitro/tasks # List all tasks
|
|
93
|
+
GET /_nitro/tasks/jobs:send-email # Run task
|
|
94
|
+
POST /_nitro/tasks/jobs:send-email # Run with payload
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 4. CLI
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npx nitro task list
|
|
101
|
+
npx nitro task run jobs:send-email --payload '{"to":"user@example.com"}'
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Critical Limitation: Single Instance (By Design)
|
|
105
|
+
|
|
106
|
+
**Each task can only have ONE running instance at a time.**
|
|
107
|
+
|
|
108
|
+
If you call `runTask("my-task")` while it's already running:
|
|
109
|
+
- The second call returns the SAME result as the first
|
|
110
|
+
- It does NOT queue or start a new execution
|
|
111
|
+
- No error is thrown
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
// These share the same execution!
|
|
115
|
+
const [result1, result2] = await Promise.all([
|
|
116
|
+
runTask("long-running-task"),
|
|
117
|
+
runTask("long-running-task"),
|
|
118
|
+
]);
|
|
119
|
+
// result1 === result2
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Workaround: Database Job Queue
|
|
123
|
+
|
|
124
|
+
For true background processing, use a database-backed queue:
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
// 1. Enqueue job
|
|
128
|
+
export async function enqueueJob(jobType: string, payload: any) {
|
|
129
|
+
const db = useDatabase();
|
|
130
|
+
const [job] = await db
|
|
131
|
+
.insertInto("job_queue")
|
|
132
|
+
.values({
|
|
133
|
+
job_type: jobType,
|
|
134
|
+
payload: JSON.stringify(payload),
|
|
135
|
+
status: "pending",
|
|
136
|
+
created_at: new Date(),
|
|
137
|
+
})
|
|
138
|
+
.returning(["id"])
|
|
139
|
+
.execute();
|
|
140
|
+
return job.id;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 2. Job dispatcher (runs every few seconds)
|
|
144
|
+
// server/tasks/scheduled/job-dispatcher.ts
|
|
145
|
+
export default defineTask({
|
|
146
|
+
meta: { name: "scheduled:job-dispatcher" },
|
|
147
|
+
async run() {
|
|
148
|
+
const db = useDatabase();
|
|
149
|
+
|
|
150
|
+
// Dequeue with locking
|
|
151
|
+
const job = await db.transaction().execute(async (trx) => {
|
|
152
|
+
const job = await trx
|
|
153
|
+
.selectFrom("job_queue")
|
|
154
|
+
.selectAll()
|
|
155
|
+
.where("status", "=", "pending")
|
|
156
|
+
.orderBy("created_at", "asc")
|
|
157
|
+
.forUpdate() // Lock row
|
|
158
|
+
.skipLocked() // Skip locked rows
|
|
159
|
+
.limit(1)
|
|
160
|
+
.executeTakeFirst();
|
|
161
|
+
|
|
162
|
+
if (!job) return null;
|
|
163
|
+
|
|
164
|
+
await trx
|
|
165
|
+
.updateTable("job_queue")
|
|
166
|
+
.set({ status: "processing", started_at: new Date() })
|
|
167
|
+
.where("id", "=", job.id)
|
|
168
|
+
.execute();
|
|
169
|
+
|
|
170
|
+
return job;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (!job) return { result: "No jobs" };
|
|
174
|
+
|
|
175
|
+
// Fire and forget the work
|
|
176
|
+
processJob(job).catch(console.error);
|
|
177
|
+
|
|
178
|
+
return { result: `Dispatched job ${job.id}` };
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
// nuxt.config.ts
|
|
185
|
+
scheduledTasks: {
|
|
186
|
+
"*/2 * * * * *": ["scheduled:job-dispatcher"], // Every 2 seconds
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Retry Logic
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
export async function markJobFailed(jobId: number, error: Error) {
|
|
194
|
+
const db = useDatabase();
|
|
195
|
+
const job = await db
|
|
196
|
+
.selectFrom("job_queue")
|
|
197
|
+
.select(["attempt_count", "max_attempts"])
|
|
198
|
+
.where("id", "=", jobId)
|
|
199
|
+
.executeTakeFirst();
|
|
200
|
+
|
|
201
|
+
const shouldRetry = (job.attempt_count || 0) < (job.max_attempts || 3);
|
|
202
|
+
|
|
203
|
+
if (shouldRetry) {
|
|
204
|
+
// Exponential backoff: 1min, 2min, 4min...
|
|
205
|
+
const delay = Math.pow(2, job.attempt_count || 0) * 60000;
|
|
206
|
+
const jitter = Math.random() * 0.1 * delay;
|
|
207
|
+
|
|
208
|
+
await db
|
|
209
|
+
.updateTable("job_queue")
|
|
210
|
+
.set({
|
|
211
|
+
status: "pending",
|
|
212
|
+
scheduled_at: new Date(Date.now() + delay + jitter),
|
|
213
|
+
error_message: error.message,
|
|
214
|
+
})
|
|
215
|
+
.where("id", "=", jobId)
|
|
216
|
+
.execute();
|
|
217
|
+
} else {
|
|
218
|
+
await db
|
|
219
|
+
.updateTable("job_queue")
|
|
220
|
+
.set({ status: "failed", error_message: error.message })
|
|
221
|
+
.where("id", "=", jobId)
|
|
222
|
+
.execute();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## When to Use Each Pattern
|
|
228
|
+
|
|
229
|
+
| Pattern | Use Case |
|
|
230
|
+
|---------|----------|
|
|
231
|
+
| `runTask` with await | One-off tasks, need result |
|
|
232
|
+
| Fire-and-forget | Background work, no result needed |
|
|
233
|
+
| Scheduled tasks | Recurring jobs (cleanup, reports) |
|
|
234
|
+
| DB job queue | Concurrent jobs, retries, reliability |
|
|
235
|
+
|
|
236
|
+
## Key Gotchas
|
|
237
|
+
|
|
238
|
+
1. **Single instance limitation** - Can't run same task twice concurrently
|
|
239
|
+
2. **No built-in queue** - Multiple calls share result
|
|
240
|
+
3. **Scheduled tasks need server** - Won't work with `nuxt generate`
|
|
241
|
+
4. **Dev API only in dev** - `/_nitro/tasks/*` not in production
|
|
242
|
+
5. **Cron needs specific runtimes** - node-server, bun, deno-server, cloudflare
|
|
243
|
+
6. **Fire-and-forget errors** - Must add `.catch()` or errors are swallowed
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# Page Structure
|
|
2
|
+
|
|
3
|
+
Pages should be thin. Keep logic in components, use pages only for layout and routing.
|
|
4
|
+
|
|
5
|
+
## The Pattern
|
|
6
|
+
|
|
7
|
+
```vue
|
|
8
|
+
<!-- pages/users/[id].vue -->
|
|
9
|
+
<script setup lang="ts">
|
|
10
|
+
// 1. Parse route params
|
|
11
|
+
const route = useRoute();
|
|
12
|
+
const userId = computed(() => route.params.id as string);
|
|
13
|
+
|
|
14
|
+
// 2. Maybe check auth/permissions
|
|
15
|
+
const { user } = useUserSession();
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<!-- 3. Layout + components only -->
|
|
20
|
+
<div class="page-container">
|
|
21
|
+
<PageHeader title="User Profile" />
|
|
22
|
+
|
|
23
|
+
<!-- Pass parsed params to components -->
|
|
24
|
+
<UserProfile :user-id="userId" />
|
|
25
|
+
|
|
26
|
+
<UserActivity :user-id="userId" v-if="user?.role === 'admin'" />
|
|
27
|
+
</div>
|
|
28
|
+
</template>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## What Goes Where
|
|
32
|
+
|
|
33
|
+
| In Page | In Component |
|
|
34
|
+
|---------|--------------|
|
|
35
|
+
| Route param parsing | Data fetching (useFetch) |
|
|
36
|
+
| Layout structure | Business logic |
|
|
37
|
+
| Component composition | Form handling |
|
|
38
|
+
| Auth guards (via middleware) | State management |
|
|
39
|
+
| Page meta (title, middleware) | Event handlers |
|
|
40
|
+
|
|
41
|
+
## Pages Do
|
|
42
|
+
|
|
43
|
+
```vue
|
|
44
|
+
<script setup lang="ts">
|
|
45
|
+
// ✅ Route params
|
|
46
|
+
const route = useRoute();
|
|
47
|
+
const id = computed(() => route.params.id as string);
|
|
48
|
+
|
|
49
|
+
// ✅ Query params (for passing to components)
|
|
50
|
+
const tab = computed(() => (route.query.tab as string) || 'overview');
|
|
51
|
+
|
|
52
|
+
// ✅ Page metadata
|
|
53
|
+
definePageMeta({
|
|
54
|
+
middleware: 'auth',
|
|
55
|
+
layout: 'dashboard',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ✅ Page title
|
|
59
|
+
useHead({ title: 'User Profile' });
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
<template>
|
|
63
|
+
<!-- ✅ Layout wrapper -->
|
|
64
|
+
<NuxtLayout>
|
|
65
|
+
<!-- ✅ Component composition -->
|
|
66
|
+
<UserHeader :id="id" />
|
|
67
|
+
<UserTabs :active-tab="tab" :user-id="id" />
|
|
68
|
+
</NuxtLayout>
|
|
69
|
+
</template>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Pages Don't
|
|
73
|
+
|
|
74
|
+
```vue
|
|
75
|
+
<script setup lang="ts">
|
|
76
|
+
// ❌ Data fetching - move to component
|
|
77
|
+
const { data: user } = await useFetch(`/api/users/${route.params.id}`);
|
|
78
|
+
|
|
79
|
+
// ❌ Complex computed - move to component
|
|
80
|
+
const fullName = computed(() => `${user.value?.firstName} ${user.value?.lastName}`);
|
|
81
|
+
|
|
82
|
+
// ❌ Event handlers - move to component
|
|
83
|
+
const handleSave = async () => {
|
|
84
|
+
await $fetch(`/api/users/${route.params.id}`, { method: 'PATCH', body: form });
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// ❌ Form state - move to component
|
|
88
|
+
const form = reactive({ name: '', email: '' });
|
|
89
|
+
|
|
90
|
+
// ❌ Watchers - move to component
|
|
91
|
+
watch(user, (newUser) => {
|
|
92
|
+
form.name = newUser?.name || '';
|
|
93
|
+
});
|
|
94
|
+
</script>
|
|
95
|
+
|
|
96
|
+
<template>
|
|
97
|
+
<!-- ❌ Too much logic in template -->
|
|
98
|
+
<form @submit.prevent="handleSave">
|
|
99
|
+
<input v-model="form.name" />
|
|
100
|
+
<input v-model="form.email" />
|
|
101
|
+
<button type="submit">Save</button>
|
|
102
|
+
</form>
|
|
103
|
+
</template>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Component Does the Work
|
|
107
|
+
|
|
108
|
+
```vue
|
|
109
|
+
<!-- components/UserProfile.vue -->
|
|
110
|
+
<script setup lang="ts">
|
|
111
|
+
const props = defineProps<{
|
|
112
|
+
userId: string;
|
|
113
|
+
}>();
|
|
114
|
+
|
|
115
|
+
// ✅ Data fetching in component
|
|
116
|
+
const { data: user, refresh } = await useFetch(() => `/api/users/${props.userId}`);
|
|
117
|
+
|
|
118
|
+
// ✅ Form state
|
|
119
|
+
const form = reactive({ name: '', email: '' });
|
|
120
|
+
|
|
121
|
+
// ✅ Sync form with data
|
|
122
|
+
watch(user, (newUser) => {
|
|
123
|
+
if (newUser) {
|
|
124
|
+
form.name = newUser.name;
|
|
125
|
+
form.email = newUser.email;
|
|
126
|
+
}
|
|
127
|
+
}, { immediate: true });
|
|
128
|
+
|
|
129
|
+
// ✅ Event handlers
|
|
130
|
+
const handleSave = async () => {
|
|
131
|
+
await $fetch(`/api/users/${props.userId}`, {
|
|
132
|
+
method: 'PATCH',
|
|
133
|
+
body: form,
|
|
134
|
+
});
|
|
135
|
+
refresh();
|
|
136
|
+
};
|
|
137
|
+
</script>
|
|
138
|
+
|
|
139
|
+
<template>
|
|
140
|
+
<form @submit.prevent="handleSave">
|
|
141
|
+
<input v-model="form.name" placeholder="Name" />
|
|
142
|
+
<input v-model="form.email" placeholder="Email" />
|
|
143
|
+
<button type="submit">Save</button>
|
|
144
|
+
</form>
|
|
145
|
+
</template>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Benefits
|
|
149
|
+
|
|
150
|
+
1. **Reusability** - Components can be used in multiple pages
|
|
151
|
+
2. **Testability** - Components are easier to test in isolation
|
|
152
|
+
3. **Readability** - Pages show structure at a glance
|
|
153
|
+
4. **Maintainability** - Changes to logic don't affect page layout
|
|
154
|
+
5. **Code splitting** - Nuxt can better optimize component loading
|
|
155
|
+
|
|
156
|
+
## Key Gotchas
|
|
157
|
+
|
|
158
|
+
1. **Don't fetch in pages** - Let components own their data
|
|
159
|
+
2. **Props down, events up** - Pass params as props, emit events for actions
|
|
160
|
+
3. **Pages are entry points** - Think of them as "controllers" that compose "views"
|
|
161
|
+
4. **Middleware for auth** - Use `definePageMeta({ middleware: 'auth' })`, not inline checks
|
|
162
|
+
5. **Layouts for shared UI** - Headers, footers, sidebars go in `/layouts`, not repeated in pages
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# Server-Side Service Integrations
|
|
2
|
+
|
|
3
|
+
> **Example:** [service-util.ts](./examples/service-util.ts)
|
|
4
|
+
|
|
5
|
+
Composable-style utilities for third-party services in `/server/utils/`.
|
|
6
|
+
|
|
7
|
+
## Basic Pattern
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
// server/utils/stripe.ts
|
|
11
|
+
import Stripe from "stripe";
|
|
12
|
+
|
|
13
|
+
// Initialize at module level with runtime config
|
|
14
|
+
const config = useRuntimeConfig();
|
|
15
|
+
const stripe = new Stripe(config.stripe.secretKey);
|
|
16
|
+
|
|
17
|
+
// Define typed methods
|
|
18
|
+
async function createPaymentIntent(options: {
|
|
19
|
+
amount: number;
|
|
20
|
+
currency: string;
|
|
21
|
+
metadata?: Record<string, string>;
|
|
22
|
+
}) {
|
|
23
|
+
return stripe.paymentIntents.create({
|
|
24
|
+
amount: options.amount,
|
|
25
|
+
currency: options.currency,
|
|
26
|
+
metadata: options.metadata,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function getCustomer(customerId: string) {
|
|
31
|
+
return stripe.customers.retrieve(customerId);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Export as use*()
|
|
35
|
+
export function useStripe() {
|
|
36
|
+
return { createPaymentIntent, getCustomer, client: stripe };
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage in API Handlers
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
// server/api/checkout/create.post.ts
|
|
44
|
+
export default defineEventHandler(async (event) => {
|
|
45
|
+
const { amount, currency } = await readBody(event);
|
|
46
|
+
|
|
47
|
+
const { createPaymentIntent } = useStripe();
|
|
48
|
+
|
|
49
|
+
const intent = await createPaymentIntent({
|
|
50
|
+
amount,
|
|
51
|
+
currency,
|
|
52
|
+
metadata: { source: "web" },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return { clientSecret: intent.client_secret };
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Service Composition
|
|
60
|
+
|
|
61
|
+
Services can use other services:
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
// server/utils/orders.ts
|
|
65
|
+
export function useOrders() {
|
|
66
|
+
const db = useDatabase();
|
|
67
|
+
const { createPaymentIntent } = useStripe();
|
|
68
|
+
|
|
69
|
+
async function createOrder(userId: number, items: CartItem[]) {
|
|
70
|
+
const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
|
|
71
|
+
|
|
72
|
+
// Create payment intent with Stripe
|
|
73
|
+
const paymentIntent = await createPaymentIntent({
|
|
74
|
+
amount: total,
|
|
75
|
+
currency: "usd",
|
|
76
|
+
metadata: { userId: String(userId) },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Save order to database
|
|
80
|
+
const order = await db
|
|
81
|
+
.insertInto("orders")
|
|
82
|
+
.values({
|
|
83
|
+
user_id: userId,
|
|
84
|
+
total,
|
|
85
|
+
stripe_payment_intent_id: paymentIntent.id,
|
|
86
|
+
status: "pending",
|
|
87
|
+
})
|
|
88
|
+
.returning(["id"])
|
|
89
|
+
.executeTakeFirst();
|
|
90
|
+
|
|
91
|
+
return { order, clientSecret: paymentIntent.client_secret };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { createOrder };
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Lazy Initialization
|
|
99
|
+
|
|
100
|
+
For expensive clients:
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
// server/utils/redis.ts
|
|
104
|
+
let redis: Redis | null = null;
|
|
105
|
+
|
|
106
|
+
export function useRedis(): Redis {
|
|
107
|
+
if (!redis) {
|
|
108
|
+
const config = useRuntimeConfig();
|
|
109
|
+
|
|
110
|
+
if (!config.redis?.url) {
|
|
111
|
+
throw new Error("NUXT_REDIS_URL not configured");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
redis = new Redis(config.redis.url);
|
|
115
|
+
redis.on("error", (err) => console.error("Redis error:", err));
|
|
116
|
+
redis.on("connect", () => console.log("Redis connected"));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return redis;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Health check
|
|
123
|
+
export async function isRedisAvailable(): Promise<boolean> {
|
|
124
|
+
try {
|
|
125
|
+
await useRedis().ping();
|
|
126
|
+
return true;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Error Handling
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
// server/utils/error-handling.ts
|
|
137
|
+
export function formatServiceError(error: unknown, service: string) {
|
|
138
|
+
const err = error as any;
|
|
139
|
+
|
|
140
|
+
// PostgreSQL constraint violations
|
|
141
|
+
if (err?.code === "23505") {
|
|
142
|
+
return { status: 409, message: "Resource already exists" };
|
|
143
|
+
}
|
|
144
|
+
if (err?.code === "23503") {
|
|
145
|
+
return { status: 400, message: "Referenced resource not found" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Network errors
|
|
149
|
+
if (err?.code === "ECONNREFUSED" || err?.code === "ETIMEDOUT") {
|
|
150
|
+
return { status: 503, message: `${service} service unavailable` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// API errors
|
|
154
|
+
if (err?.response?.status) {
|
|
155
|
+
return { status: err.response.status, message: err.message };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { status: 500, message: `${service} error: ${err?.message}` };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Usage
|
|
162
|
+
async function callExternalApi() {
|
|
163
|
+
try {
|
|
164
|
+
return await client.doSomething();
|
|
165
|
+
} catch (error) {
|
|
166
|
+
const { status, message } = formatServiceError(error, "Stripe");
|
|
167
|
+
throw createError({ statusCode: status, message });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Transaction Pattern
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
// server/utils/invoices.ts
|
|
176
|
+
export function useInvoices() {
|
|
177
|
+
const db = useDatabase();
|
|
178
|
+
|
|
179
|
+
async function createInvoice(params: CreateParams) {
|
|
180
|
+
return await db.transaction().execute(async (trx) => {
|
|
181
|
+
// All operations use trx, not db
|
|
182
|
+
const invoice = await trx
|
|
183
|
+
.insertInto("invoice")
|
|
184
|
+
.values(params)
|
|
185
|
+
.returning(["id"])
|
|
186
|
+
.executeTakeFirst();
|
|
187
|
+
|
|
188
|
+
await trx
|
|
189
|
+
.updateTable("session")
|
|
190
|
+
.set({ invoice_id: invoice.id, locked: true })
|
|
191
|
+
.where("id", "in", params.sessionIds)
|
|
192
|
+
.execute();
|
|
193
|
+
|
|
194
|
+
return invoice;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { createInvoice };
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Common Structure
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
// server/utils/[service].ts
|
|
206
|
+
|
|
207
|
+
// 1. Import SDK
|
|
208
|
+
import { ServiceClient } from "service-sdk";
|
|
209
|
+
|
|
210
|
+
// 2. Initialize with runtime config
|
|
211
|
+
const config = useRuntimeConfig();
|
|
212
|
+
const client = new ServiceClient({ apiKey: config.service.apiKey });
|
|
213
|
+
|
|
214
|
+
// 3. Define typed methods
|
|
215
|
+
async function doAction(params: ActionParams): Promise<ActionResult> {
|
|
216
|
+
try {
|
|
217
|
+
return await client.action(params);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
throw createError({ statusCode: 500, message: error.message });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 4. Export as use*()
|
|
224
|
+
export function useService() {
|
|
225
|
+
return {
|
|
226
|
+
doAction,
|
|
227
|
+
client, // Expose for advanced usage
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Key Gotchas
|
|
233
|
+
|
|
234
|
+
1. **Config at module level** - `useRuntimeConfig()` works at module scope
|
|
235
|
+
2. **Singleton clients** - Initialize once, reuse across requests
|
|
236
|
+
3. **Composition order** - Call use*() inside functions, not module level
|
|
237
|
+
4. **Error transformation** - Convert SDK errors to HTTP errors
|
|
238
|
+
5. **Transaction scope** - Pass `trx` when in transaction
|