@donkeylabs/server 1.1.15 → 1.1.16
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/CLAUDE.md +84 -1108
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -6,1172 +6,148 @@ alwaysApply: true
|
|
|
6
6
|
|
|
7
7
|
# DonkeyLabs Project
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
---
|
|
9
|
+
SvelteKit + @donkeylabs/server full-stack app with type-safe APIs and Svelte 5.
|
|
12
10
|
|
|
13
11
|
## CRITICAL RULES
|
|
14
12
|
|
|
15
13
|
### 1. Use MCP Tools First
|
|
14
|
+
When `donkeylabs` MCP is available, use tools instead of manual code: `create_plugin`, `add_route`, `add_migration`, `add_service_method`, `generate_types`.
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
| Task | MCP Tool |
|
|
20
|
-
|------|----------|
|
|
21
|
-
| Create a plugin | `create_plugin` |
|
|
22
|
-
| Add a route | `add_route` |
|
|
23
|
-
| Add database migration | `add_migration` |
|
|
24
|
-
| Add service method | `add_service_method` |
|
|
25
|
-
| Generate types | `generate_types` |
|
|
26
|
-
|
|
27
|
-
### 2. Database Migrations - KYSELY ONLY
|
|
28
|
-
|
|
29
|
-
**CRITICAL: Migrations MUST use Kysely schema builder. NEVER use raw SQL.**
|
|
30
|
-
|
|
16
|
+
### 2. Migrations - KYSELY ONLY (No Raw SQL)
|
|
31
17
|
```ts
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
.createTable("users")
|
|
38
|
-
.ifNotExists()
|
|
39
|
-
.addColumn("id", "text", (col) => col.primaryKey())
|
|
40
|
-
.addColumn("email", "text", (col) => col.notNull().unique())
|
|
41
|
-
.addColumn("name", "text", (col) => col.notNull())
|
|
42
|
-
.addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
|
|
43
|
-
.execute();
|
|
44
|
-
|
|
45
|
-
await db.schema
|
|
46
|
-
.createIndex("idx_users_email")
|
|
47
|
-
.ifNotExists()
|
|
48
|
-
.on("users")
|
|
49
|
-
.column("email")
|
|
50
|
-
.execute();
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export async function down(db: Kysely<any>): Promise<void> {
|
|
54
|
-
await db.schema.dropTable("users").ifExists().execute();
|
|
55
|
-
}
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
```ts
|
|
59
|
-
// ❌ WRONG - Never do this
|
|
60
|
-
import { sql } from "kysely";
|
|
18
|
+
// CORRECT - Kysely schema builder
|
|
19
|
+
await db.schema.createTable("users").ifNotExists()
|
|
20
|
+
.addColumn("id", "text", (col) => col.primaryKey())
|
|
21
|
+
.addColumn("email", "text", (col) => col.notNull().unique())
|
|
22
|
+
.execute();
|
|
61
23
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
await db.executeQuery(sql`ALTER TABLE...`); // NO!
|
|
65
|
-
}
|
|
24
|
+
// WRONG - Never use raw SQL
|
|
25
|
+
await sql`CREATE TABLE...`.execute(db); // NO!
|
|
66
26
|
```
|
|
67
27
|
|
|
68
|
-
**Kysely schema builder methods:**
|
|
69
|
-
- `createTable()`, `dropTable()`, `alterTable()`
|
|
70
|
-
- `addColumn()`, `dropColumn()`, `renameColumn()`
|
|
71
|
-
- `createIndex()`, `dropIndex()`
|
|
72
|
-
- Column modifiers: `.primaryKey()`, `.notNull()`, `.unique()`, `.defaultTo()`, `.references()`
|
|
73
|
-
|
|
74
28
|
### 3. Frontend - Svelte 5 & shadcn-svelte ONLY
|
|
75
|
-
|
|
76
|
-
**UI Components:** Use **shadcn-svelte** exclusively. Never use other UI libraries.
|
|
77
|
-
|
|
78
29
|
```svelte
|
|
79
|
-
<!-- ✅ CORRECT - shadcn-svelte components -->
|
|
80
30
|
<script lang="ts">
|
|
81
31
|
import { Button } from "$lib/components/ui/button";
|
|
82
|
-
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
|
|
83
|
-
import { Input } from "$lib/components/ui/input";
|
|
84
|
-
</script>
|
|
85
|
-
|
|
86
|
-
<Card>
|
|
87
|
-
<CardHeader>
|
|
88
|
-
<CardTitle>My Card</CardTitle>
|
|
89
|
-
</CardHeader>
|
|
90
|
-
<CardContent>
|
|
91
|
-
<Input placeholder="Enter text" />
|
|
92
|
-
<Button onclick={handleClick}>Submit</Button>
|
|
93
|
-
</CardContent>
|
|
94
|
-
</Card>
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
**Svelte 5 Patterns - NEVER use $effect, use `watch` from runed:**
|
|
98
|
-
|
|
99
|
-
```svelte
|
|
100
|
-
<!-- ✅ CORRECT - Svelte 5 runes with runed -->
|
|
101
|
-
<script lang="ts">
|
|
102
|
-
import { onMount } from "svelte";
|
|
103
32
|
import { watch } from "runed";
|
|
33
|
+
import { onMount } from "svelte";
|
|
104
34
|
|
|
105
|
-
// Props
|
|
106
35
|
let { data } = $props();
|
|
107
|
-
|
|
108
|
-
// Reactive state
|
|
109
36
|
let count = $state(0);
|
|
110
|
-
let items = $state<string[]>([]);
|
|
111
|
-
|
|
112
|
-
// Derived values
|
|
113
37
|
let doubled = $derived(count * 2);
|
|
114
|
-
let
|
|
115
|
-
|
|
116
|
-
// ✅ CORRECT - Use watch from runed for reactive side effects
|
|
117
|
-
watch(
|
|
118
|
-
() => count,
|
|
119
|
-
(newCount) => {
|
|
120
|
-
console.log("Count changed to:", newCount);
|
|
121
|
-
// React to count changes
|
|
122
|
-
}
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
// ✅ CORRECT - Watch multiple values
|
|
126
|
-
watch(
|
|
127
|
-
() => [count, items.length],
|
|
128
|
-
([newCount, newLength]) => {
|
|
129
|
-
console.log("Values changed:", newCount, newLength);
|
|
130
|
-
}
|
|
131
|
-
);
|
|
132
|
-
|
|
133
|
-
// Lifecycle - use onMount for setup/cleanup
|
|
134
|
-
onMount(() => {
|
|
135
|
-
// Setup code here
|
|
136
|
-
fetchData();
|
|
137
|
-
|
|
138
|
-
return () => {
|
|
139
|
-
// Cleanup code here
|
|
140
|
-
};
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
// Event handlers
|
|
144
|
-
function handleClick() {
|
|
145
|
-
count++;
|
|
146
|
-
}
|
|
147
|
-
</script>
|
|
38
|
+
let users = $derived(data.users); // NOT $state(data.users)
|
|
148
39
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
```
|
|
40
|
+
// Use watch for effects, NEVER $effect
|
|
41
|
+
watch(() => count, (val) => console.log(val));
|
|
152
42
|
|
|
153
|
-
|
|
154
|
-
<!-- ❌ WRONG - Never use $effect -->
|
|
155
|
-
<script lang="ts">
|
|
156
|
-
let count = $state(0);
|
|
157
|
-
|
|
158
|
-
// ❌ NEVER DO THIS - use watch from runed instead
|
|
159
|
-
$effect(() => {
|
|
160
|
-
console.log(count); // NO!
|
|
161
|
-
});
|
|
43
|
+
onMount(() => { /* setup */ return () => { /* cleanup */ }; });
|
|
162
44
|
</script>
|
|
45
|
+
<Button onclick={() => count++}>Click</Button>
|
|
163
46
|
```
|
|
164
47
|
|
|
165
|
-
**Svelte 5 Rules:**
|
|
166
|
-
- Use `$state()` for reactive variables
|
|
167
|
-
- Use `$derived()` for computed values
|
|
168
|
-
- Use `$props()` to receive props
|
|
169
|
-
- Use `watch()` from **runed** for reactive side effects, **NEVER $effect**
|
|
170
|
-
- Use `onMount()` for lifecycle setup/cleanup
|
|
171
|
-
- Use `onclick={}` not `on:click={}`
|
|
172
|
-
- Use `{@render children()}` for slots/snippets
|
|
173
|
-
|
|
174
|
-
---
|
|
175
|
-
|
|
176
48
|
## Project Structure
|
|
177
|
-
|
|
178
|
-
```
|
|
179
|
-
my-project/
|
|
180
|
-
├── src/
|
|
181
|
-
│ ├── server/ # @donkeylabs/server API
|
|
182
|
-
│ │ ├── index.ts # Server entry point
|
|
183
|
-
│ │ ├── plugins/ # Business logic plugins
|
|
184
|
-
│ │ │ └── users/
|
|
185
|
-
│ │ │ ├── index.ts # Plugin definition
|
|
186
|
-
│ │ │ └── migrations/ # Kysely migrations
|
|
187
|
-
│ │ │ └── 001_create_users_table.ts
|
|
188
|
-
│ │ └── routes/ # API route definitions
|
|
189
|
-
│ │ └── users.ts # User routes
|
|
190
|
-
│ │
|
|
191
|
-
│ ├── lib/
|
|
192
|
-
│ │ ├── api.ts # Generated typed API client (DO NOT EDIT)
|
|
193
|
-
│ │ ├── components/ui/ # shadcn-svelte components
|
|
194
|
-
│ │ └── utils/ # Utility functions
|
|
195
|
-
│ │
|
|
196
|
-
│ ├── routes/ # SvelteKit pages
|
|
197
|
-
│ │ ├── +layout.svelte
|
|
198
|
-
│ │ ├── +page.svelte
|
|
199
|
-
│ │ └── +page.server.ts
|
|
200
|
-
│ │
|
|
201
|
-
│ ├── app.html
|
|
202
|
-
│ ├── app.css
|
|
203
|
-
│ └── hooks.server.ts # SvelteKit hooks
|
|
204
|
-
│
|
|
205
|
-
├── docs/ # Documentation
|
|
206
|
-
├── .mcp.json # MCP server config
|
|
207
|
-
├── svelte.config.js
|
|
208
|
-
├── vite.config.ts
|
|
209
|
-
└── package.json
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
---
|
|
213
|
-
|
|
214
|
-
## Common Pitfalls - AVOID THESE
|
|
215
|
-
|
|
216
|
-
### 1. State from Props - Does NOT Auto-Update
|
|
217
|
-
|
|
218
|
-
```svelte
|
|
219
|
-
<!-- ❌ WRONG - This only copies initial value, won't update when data changes -->
|
|
220
|
-
<script lang="ts">
|
|
221
|
-
let { data } = $props();
|
|
222
|
-
let users = $state(data.users); // BROKEN! Won't update on navigation
|
|
223
|
-
</script>
|
|
224
|
-
|
|
225
|
-
<!-- ✅ CORRECT - Use $derived for reactive props -->
|
|
226
|
-
<script lang="ts">
|
|
227
|
-
let { data } = $props();
|
|
228
|
-
let users = $derived(data.users); // Updates when data changes
|
|
229
|
-
|
|
230
|
-
// Or if you need to mutate locally:
|
|
231
|
-
let localUsers = $state<User[]>([]);
|
|
232
|
-
watch(() => data.users, (newUsers) => {
|
|
233
|
-
localUsers = [...newUsers];
|
|
234
|
-
});
|
|
235
|
-
</script>
|
|
236
|
-
```
|
|
237
|
-
|
|
238
|
-
### 2. Loading States - Always Handle Async Properly
|
|
239
|
-
|
|
240
|
-
```svelte
|
|
241
|
-
<!-- ✅ CORRECT - Track loading state -->
|
|
242
|
-
<script lang="ts">
|
|
243
|
-
import { Button } from "$lib/components/ui/button";
|
|
244
|
-
|
|
245
|
-
let loading = $state(false);
|
|
246
|
-
let error = $state<string | null>(null);
|
|
247
|
-
let data = $state<Data | null>(null);
|
|
248
|
-
|
|
249
|
-
async function fetchData() {
|
|
250
|
-
loading = true;
|
|
251
|
-
error = null;
|
|
252
|
-
try {
|
|
253
|
-
data = await api.users.list({});
|
|
254
|
-
} catch (e) {
|
|
255
|
-
error = e instanceof Error ? e.message : "Failed to load";
|
|
256
|
-
} finally {
|
|
257
|
-
loading = false;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
</script>
|
|
261
|
-
|
|
262
|
-
<Button onclick={fetchData} disabled={loading}>
|
|
263
|
-
{loading ? "Loading..." : "Refresh"}
|
|
264
|
-
</Button>
|
|
265
|
-
{#if error}
|
|
266
|
-
<p class="text-destructive">{error}</p>
|
|
267
|
-
{/if}
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
### 3. API Client - SSR vs Browser
|
|
271
|
-
|
|
272
|
-
```ts
|
|
273
|
-
// +page.server.ts - SSR: Pass locals for direct calls (no HTTP)
|
|
274
|
-
export const load = async ({ locals }) => {
|
|
275
|
-
const api = createApi({ locals }); // ✅ Direct call
|
|
276
|
-
return { users: await api.users.list({}) };
|
|
277
|
-
};
|
|
278
|
-
|
|
279
|
-
// +page.svelte - Browser: No locals needed (uses HTTP)
|
|
280
|
-
<script lang="ts">
|
|
281
|
-
const api = createApi(); // ✅ HTTP calls
|
|
282
|
-
|
|
283
|
-
// ❌ WRONG - Don't try to pass locals in browser
|
|
284
|
-
// const api = createApi({ locals }); // Won't work!
|
|
285
|
-
</script>
|
|
286
|
-
```
|
|
287
|
-
|
|
288
|
-
### 5. NEVER Use Raw fetch() or EventSource - Use the Generated Client
|
|
289
|
-
|
|
290
|
-
**The generated client in `$lib/api.ts` handles everything. NEVER bypass it.**
|
|
291
|
-
|
|
292
|
-
```ts
|
|
293
|
-
// ❌ WRONG - Never use raw fetch
|
|
294
|
-
const response = await fetch('/users.get', {
|
|
295
|
-
method: 'POST',
|
|
296
|
-
body: JSON.stringify({ id: '123' })
|
|
297
|
-
});
|
|
298
|
-
const user = await response.json();
|
|
299
|
-
|
|
300
|
-
// ✅ CORRECT - Use the typed client
|
|
301
|
-
const api = createApi();
|
|
302
|
-
const user = await api.users.get({ id: '123' });
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
```ts
|
|
306
|
-
// ❌ WRONG - Never use raw EventSource
|
|
307
|
-
const eventSource = new EventSource('/sse?channels=notifications');
|
|
308
|
-
eventSource.onmessage = (e) => console.log(e.data);
|
|
309
|
-
|
|
310
|
-
// ✅ CORRECT - Use client.sse.subscribe()
|
|
311
|
-
const api = createApi();
|
|
312
|
-
const unsubscribe = api.sse.subscribe(
|
|
313
|
-
['notifications'],
|
|
314
|
-
(event, data) => {
|
|
315
|
-
console.log(event, data); // Typed and parsed!
|
|
316
|
-
}
|
|
317
|
-
);
|
|
318
|
-
// Later: unsubscribe();
|
|
319
|
-
```
|
|
320
|
-
|
|
321
|
-
**The client provides:**
|
|
322
|
-
- Type safety for all inputs/outputs
|
|
323
|
-
- Automatic JSON parsing
|
|
324
|
-
- SSR direct calls (no HTTP overhead when using `{ locals }`)
|
|
325
|
-
- Auto-reconnect for SSE
|
|
326
|
-
- Proper error handling
|
|
327
|
-
|
|
328
|
-
### 4. Migration Files MUST Be Numbered Sequentially
|
|
329
|
-
|
|
330
|
-
```
|
|
331
|
-
src/server/plugins/users/migrations/
|
|
332
|
-
├── 001_create_users_table.ts ✅ First migration
|
|
333
|
-
├── 002_add_avatar_column.ts ✅ Second migration
|
|
334
|
-
├── 003_create_sessions_table.ts ✅ Third migration
|
|
335
|
-
└── create_something.ts ❌ WRONG - No number prefix!
|
|
336
|
-
```
|
|
337
|
-
|
|
338
|
-
---
|
|
339
|
-
|
|
340
|
-
## Schema Type Definitions
|
|
341
|
-
|
|
342
|
-
**Define table types alongside your migrations:**
|
|
343
|
-
|
|
344
|
-
```ts
|
|
345
|
-
// src/server/plugins/users/index.ts
|
|
346
|
-
import { createPlugin } from "@donkeylabs/server";
|
|
347
|
-
import type { Generated, ColumnType } from "kysely";
|
|
348
|
-
|
|
349
|
-
// Define your table schema type
|
|
350
|
-
interface UsersTable {
|
|
351
|
-
id: string;
|
|
352
|
-
email: string;
|
|
353
|
-
name: string;
|
|
354
|
-
avatar_url: string | null;
|
|
355
|
-
created_at: ColumnType<string, string | undefined, never>; // Read: string, Insert: optional, Update: never
|
|
356
|
-
updated_at: string;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// Use in plugin
|
|
360
|
-
export const usersPlugin = createPlugin
|
|
361
|
-
.withSchema<{ users: UsersTable }>()
|
|
362
|
-
.define({
|
|
363
|
-
name: "users",
|
|
364
|
-
service: async (ctx) => ({
|
|
365
|
-
// ctx.db is now typed with users table
|
|
366
|
-
}),
|
|
367
|
-
});
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
---
|
|
371
|
-
|
|
372
|
-
## Error Handling
|
|
373
|
-
|
|
374
|
-
### In Plugins - Throw Errors
|
|
375
|
-
|
|
376
|
-
```ts
|
|
377
|
-
// src/server/plugins/users/index.ts
|
|
378
|
-
service: async (ctx) => ({
|
|
379
|
-
getById: async (id: string) => {
|
|
380
|
-
const user = await ctx.db
|
|
381
|
-
.selectFrom("users")
|
|
382
|
-
.where("id", "=", id)
|
|
383
|
-
.selectAll()
|
|
384
|
-
.executeTakeFirst();
|
|
385
|
-
|
|
386
|
-
if (!user) {
|
|
387
|
-
throw ctx.errors.NotFound("User not found"); // ✅ Use ctx.errors
|
|
388
|
-
}
|
|
389
|
-
return user;
|
|
390
|
-
},
|
|
391
|
-
|
|
392
|
-
create: async (data: CreateUserInput) => {
|
|
393
|
-
// Check for duplicates
|
|
394
|
-
const existing = await ctx.db
|
|
395
|
-
.selectFrom("users")
|
|
396
|
-
.where("email", "=", data.email)
|
|
397
|
-
.selectAll()
|
|
398
|
-
.executeTakeFirst();
|
|
399
|
-
|
|
400
|
-
if (existing) {
|
|
401
|
-
throw ctx.errors.BadRequest("Email already exists"); // ✅
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// ... create user
|
|
405
|
-
},
|
|
406
|
-
}),
|
|
407
|
-
```
|
|
408
|
-
|
|
409
|
-
### Available Error Types
|
|
410
|
-
|
|
411
|
-
```ts
|
|
412
|
-
ctx.errors.BadRequest(message) // 400
|
|
413
|
-
ctx.errors.Unauthorized(message) // 401
|
|
414
|
-
ctx.errors.Forbidden(message) // 403
|
|
415
|
-
ctx.errors.NotFound(message) // 404
|
|
416
|
-
ctx.errors.Conflict(message) // 409
|
|
417
|
-
ctx.errors.InternalError(message) // 500
|
|
418
|
-
```
|
|
419
|
-
|
|
420
|
-
---
|
|
421
|
-
|
|
422
|
-
## Core Services (ctx.core)
|
|
423
|
-
|
|
424
|
-
Access built-in services via `ctx.core` in plugins:
|
|
425
|
-
|
|
426
|
-
```ts
|
|
427
|
-
service: async (ctx) => ({
|
|
428
|
-
doSomething: async () => {
|
|
429
|
-
// Logging
|
|
430
|
-
ctx.core.logger.info("Something happened", { userId: "123" });
|
|
431
|
-
ctx.core.logger.error("Failed", { error: err.message });
|
|
432
|
-
|
|
433
|
-
// Caching
|
|
434
|
-
await ctx.core.cache.set("key", value, 60000); // 60s TTL
|
|
435
|
-
const cached = await ctx.core.cache.get("key");
|
|
436
|
-
|
|
437
|
-
// Background Jobs
|
|
438
|
-
await ctx.core.jobs.enqueue("send-email", { to: "user@example.com" });
|
|
439
|
-
|
|
440
|
-
// Events (pub/sub)
|
|
441
|
-
await ctx.core.events.emit("user.created", { userId: "123" });
|
|
442
|
-
|
|
443
|
-
// Rate Limiting
|
|
444
|
-
const { allowed } = await ctx.core.rateLimiter.check("user:123", 10, 60000);
|
|
445
|
-
|
|
446
|
-
// SSE Broadcast
|
|
447
|
-
ctx.core.sse.broadcast("notifications", "new-message", { text: "Hello" });
|
|
448
|
-
},
|
|
449
|
-
}),
|
|
450
49
|
```
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
50
|
+
src/
|
|
51
|
+
├── server/
|
|
52
|
+
│ ├── index.ts # Server entry
|
|
53
|
+
│ ├── plugins/ # Business logic (reusable)
|
|
54
|
+
│ │ └── users/
|
|
55
|
+
│ │ ├── index.ts
|
|
56
|
+
│ │ └── migrations/
|
|
57
|
+
│ └── routes/ # API routes
|
|
58
|
+
│ └── orders/ # Feature modules (app-specific)
|
|
59
|
+
│ ├── index.ts
|
|
60
|
+
│ ├── orders.schemas.ts
|
|
61
|
+
│ └── handlers/
|
|
62
|
+
├── lib/
|
|
63
|
+
│ ├── api.ts # Generated client (DO NOT EDIT)
|
|
64
|
+
│ └── components/ui/ # shadcn-svelte
|
|
65
|
+
└── routes/ # SvelteKit pages
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Common Pitfalls
|
|
69
|
+
|
|
70
|
+
| Wrong | Correct |
|
|
71
|
+
|-------|---------|
|
|
72
|
+
| `let x = $state(data.x)` | `let x = $derived(data.x)` |
|
|
73
|
+
| `$effect(() => {...})` | `watch(() => val, (v) => {...})` |
|
|
74
|
+
| `await fetch('/route')` | `await api.route.method({})` |
|
|
75
|
+
| `new EventSource(...)` | `api.sse.subscribe([...], cb)` |
|
|
76
|
+
| Raw SQL in migrations | Kysely schema builder |
|
|
77
|
+
|
|
78
|
+
## Plugin Quick Reference
|
|
458
79
|
```ts
|
|
459
|
-
// src/server/plugins/orders/index.ts
|
|
460
|
-
export const ordersPlugin = createPlugin
|
|
461
|
-
.withSchema<{ orders: OrdersTable }>()
|
|
462
|
-
.define({
|
|
463
|
-
name: "orders",
|
|
464
|
-
dependencies: ["users"], // Declare dependency
|
|
465
|
-
service: async (ctx) => ({
|
|
466
|
-
createOrder: async (userId: string, items: Item[]) => {
|
|
467
|
-
// Access users plugin
|
|
468
|
-
const user = await ctx.plugins.users.getById(userId); // ✅
|
|
469
|
-
if (!user) throw ctx.errors.NotFound("User not found");
|
|
470
|
-
|
|
471
|
-
// Create order...
|
|
472
|
-
},
|
|
473
|
-
}),
|
|
474
|
-
});
|
|
475
|
-
```
|
|
476
|
-
|
|
477
|
-
---
|
|
478
|
-
|
|
479
|
-
## Generated Files - DO NOT EDIT
|
|
480
|
-
|
|
481
|
-
These files are auto-generated and will be overwritten:
|
|
482
|
-
|
|
483
|
-
```
|
|
484
|
-
src/lib/api.ts # Typed API client - regenerated by donkeylabs generate
|
|
485
|
-
.@donkeylabs/ # Type definitions - gitignored
|
|
486
|
-
```
|
|
487
|
-
|
|
488
|
-
**After ANY change to plugins, routes, or migrations, run:**
|
|
489
|
-
```sh
|
|
490
|
-
bunx donkeylabs generate
|
|
491
|
-
```
|
|
492
|
-
|
|
493
|
-
---
|
|
494
|
-
|
|
495
|
-
## Creating Features
|
|
496
|
-
|
|
497
|
-
### 1. Create a Plugin (Business Logic)
|
|
498
|
-
|
|
499
|
-
```ts
|
|
500
|
-
// src/server/plugins/users/index.ts
|
|
501
|
-
import { createPlugin } from "@donkeylabs/server";
|
|
502
|
-
|
|
503
80
|
export const usersPlugin = createPlugin
|
|
504
81
|
.withSchema<{ users: UsersTable }>()
|
|
505
82
|
.define({
|
|
506
83
|
name: "users",
|
|
507
84
|
service: async (ctx) => ({
|
|
508
|
-
getById: async (id
|
|
509
|
-
return ctx.db
|
|
510
|
-
.selectFrom("users")
|
|
511
|
-
.where("id", "=", id)
|
|
512
|
-
.selectAll()
|
|
513
|
-
.executeTakeFirst();
|
|
514
|
-
},
|
|
515
|
-
create: async (data: { email: string; name: string }) => {
|
|
516
|
-
const id = crypto.randomUUID();
|
|
517
|
-
await ctx.db
|
|
518
|
-
.insertInto("users")
|
|
519
|
-
.values({ id, ...data, created_at: new Date().toISOString() })
|
|
520
|
-
.execute();
|
|
521
|
-
return { id };
|
|
522
|
-
},
|
|
85
|
+
getById: async (id) => ctx.db.selectFrom("users").where("id", "=", id).selectAll().executeTakeFirst(),
|
|
523
86
|
}),
|
|
524
87
|
});
|
|
525
88
|
```
|
|
526
89
|
|
|
527
|
-
|
|
528
|
-
|
|
90
|
+
## Route Quick Reference
|
|
529
91
|
```ts
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
users.route("get").typed(
|
|
537
|
-
defineRoute({
|
|
538
|
-
input: z.object({ id: z.string() }),
|
|
539
|
-
output: z.object({
|
|
540
|
-
id: z.string(),
|
|
541
|
-
email: z.string(),
|
|
542
|
-
name: z.string(),
|
|
543
|
-
}).nullable(),
|
|
544
|
-
handle: async (input, ctx) => {
|
|
545
|
-
return ctx.plugins.users.getById(input.id);
|
|
546
|
-
},
|
|
547
|
-
})
|
|
548
|
-
);
|
|
549
|
-
|
|
550
|
-
users.route("create").typed(
|
|
551
|
-
defineRoute({
|
|
552
|
-
input: z.object({
|
|
553
|
-
email: z.string().email(),
|
|
554
|
-
name: z.string().min(1),
|
|
555
|
-
}),
|
|
556
|
-
output: z.object({ id: z.string() }),
|
|
557
|
-
handle: async (input, ctx) => {
|
|
558
|
-
return ctx.plugins.users.create(input);
|
|
559
|
-
},
|
|
560
|
-
})
|
|
561
|
-
);
|
|
562
|
-
|
|
563
|
-
export default users;
|
|
564
|
-
```
|
|
565
|
-
|
|
566
|
-
---
|
|
567
|
-
|
|
568
|
-
## Feature Module Pattern (Recommended for App Routes)
|
|
569
|
-
|
|
570
|
-
**For app-specific routes, use feature modules with handler classes containing business logic.**
|
|
571
|
-
|
|
572
|
-
**Plugins** = Reusable power-ups (auth, notifications, payments)
|
|
573
|
-
**Feature Modules** = App-specific handlers with business logic
|
|
574
|
-
|
|
575
|
-
### Structure
|
|
576
|
-
|
|
577
|
-
```
|
|
578
|
-
src/server/routes/orders/
|
|
579
|
-
├── index.ts # Router (thin) - just wires handlers to routes
|
|
580
|
-
├── orders.schemas.ts # Zod schemas + TypeScript types
|
|
581
|
-
├── handlers/
|
|
582
|
-
│ ├── create.handler.ts # CreateOrderHandler - contains business logic
|
|
583
|
-
│ ├── list.handler.ts # ListOrdersHandler
|
|
584
|
-
│ └── get-by-id.handler.ts # GetOrderByIdHandler
|
|
585
|
-
└── orders.test.ts # Tests for handlers
|
|
92
|
+
router.route("get").typed(defineRoute({
|
|
93
|
+
input: z.object({ id: z.string() }),
|
|
94
|
+
output: userSchema.nullable(),
|
|
95
|
+
handle: async (input, ctx) => ctx.plugins.users.getById(input.id),
|
|
96
|
+
}));
|
|
586
97
|
```
|
|
587
98
|
|
|
588
|
-
|
|
589
|
-
|
|
99
|
+
## Feature Module Pattern
|
|
100
|
+
For app-specific routes, use handler classes:
|
|
590
101
|
```ts
|
|
591
102
|
// handlers/create.handler.ts
|
|
592
|
-
import type { Handler, Routes, AppContext } from "$server/api";
|
|
593
|
-
|
|
594
103
|
export class CreateOrderHandler implements Handler<Routes.Orders.Create> {
|
|
595
104
|
constructor(private ctx: AppContext) {}
|
|
596
|
-
|
|
597
|
-
async handle(input: Routes.Orders.Create.Input): Promise<Routes.Orders.Create.Output> {
|
|
598
|
-
// Validate business rules
|
|
599
|
-
const user = await this.ctx.plugins.auth.getCurrentUser();
|
|
600
|
-
if (!user) throw this.ctx.errors.Unauthorized("Must be logged in");
|
|
601
|
-
|
|
602
|
-
// Database operations
|
|
603
|
-
const id = crypto.randomUUID();
|
|
604
|
-
await this.ctx.db
|
|
605
|
-
.insertInto("orders")
|
|
606
|
-
.values({ id, user_id: user.id, ...input })
|
|
607
|
-
.execute();
|
|
608
|
-
|
|
609
|
-
// Use plugins for cross-cutting concerns
|
|
610
|
-
await this.ctx.plugins.notifications.send(user.id, "Order created");
|
|
611
|
-
|
|
612
|
-
// Return result
|
|
613
|
-
return this.ctx.db
|
|
614
|
-
.selectFrom("orders")
|
|
615
|
-
.where("id", "=", id)
|
|
616
|
-
.selectAll()
|
|
617
|
-
.executeTakeFirstOrThrow();
|
|
618
|
-
}
|
|
105
|
+
async handle(input) { /* business logic here */ }
|
|
619
106
|
}
|
|
620
|
-
```
|
|
621
|
-
|
|
622
|
-
### Router (Thin Wiring Only)
|
|
623
|
-
|
|
624
|
-
```ts
|
|
625
|
-
// index.ts
|
|
626
|
-
import { createRouter } from "@donkeylabs/server";
|
|
627
|
-
import { createOrderSchema, orderSchema, listOrdersSchema } from "./orders.schemas";
|
|
628
|
-
import { CreateOrderHandler } from "./handlers/create.handler";
|
|
629
|
-
import { ListOrdersHandler } from "./handlers/list.handler";
|
|
630
|
-
|
|
631
|
-
export const ordersRouter = createRouter("orders")
|
|
632
|
-
|
|
633
|
-
.route("create").typed({
|
|
634
|
-
input: createOrderSchema,
|
|
635
|
-
output: orderSchema,
|
|
636
|
-
handle: CreateOrderHandler,
|
|
637
|
-
})
|
|
638
|
-
|
|
639
|
-
.route("list").typed({
|
|
640
|
-
input: listOrdersSchema,
|
|
641
|
-
output: orderSchema.array(),
|
|
642
|
-
handle: ListOrdersHandler,
|
|
643
|
-
});
|
|
644
|
-
```
|
|
645
|
-
|
|
646
|
-
### Schemas (Validation + Types)
|
|
647
|
-
|
|
648
|
-
```ts
|
|
649
|
-
// orders.schemas.ts
|
|
650
|
-
import { z } from "zod";
|
|
651
|
-
|
|
652
|
-
export const createOrderSchema = z.object({
|
|
653
|
-
items: z.array(z.object({
|
|
654
|
-
productId: z.string(),
|
|
655
|
-
quantity: z.number().int().positive(),
|
|
656
|
-
})),
|
|
657
|
-
shippingAddress: z.string(),
|
|
658
|
-
});
|
|
659
|
-
|
|
660
|
-
export const orderSchema = z.object({
|
|
661
|
-
id: z.string(),
|
|
662
|
-
status: z.enum(["pending", "paid", "shipped", "delivered"]),
|
|
663
|
-
total: z.number(),
|
|
664
|
-
createdAt: z.string(),
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
export type CreateOrderInput = z.infer<typeof createOrderSchema>;
|
|
668
|
-
export type Order = z.infer<typeof orderSchema>;
|
|
669
|
-
```
|
|
670
|
-
|
|
671
|
-
### Testing Handlers Directly
|
|
672
|
-
|
|
673
|
-
```ts
|
|
674
|
-
// orders.test.ts
|
|
675
|
-
import { describe, test, expect, beforeEach } from "bun:test";
|
|
676
|
-
import { createTestHarness } from "@donkeylabs/server/harness";
|
|
677
|
-
import { CreateOrderHandler } from "./handlers/create.handler";
|
|
678
|
-
|
|
679
|
-
describe("CreateOrderHandler", () => {
|
|
680
|
-
let handler: CreateOrderHandler;
|
|
681
|
-
|
|
682
|
-
beforeEach(async () => {
|
|
683
|
-
const { ctx } = await createTestHarness();
|
|
684
|
-
handler = new CreateOrderHandler(ctx);
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
test("creates order successfully", async () => {
|
|
688
|
-
const order = await handler.handle({
|
|
689
|
-
items: [{ productId: "prod-1", quantity: 2 }],
|
|
690
|
-
shippingAddress: "123 Main St",
|
|
691
|
-
});
|
|
692
|
-
|
|
693
|
-
expect(order.id).toBeDefined();
|
|
694
|
-
expect(order.status).toBe("pending");
|
|
695
|
-
});
|
|
696
|
-
});
|
|
697
|
-
```
|
|
698
|
-
|
|
699
|
-
### When to Use Feature Modules vs Plugins
|
|
700
|
-
|
|
701
|
-
| Use Feature Modules | Use Plugins |
|
|
702
|
-
|---------------------|-------------|
|
|
703
|
-
| App-specific routes | Reusable across projects |
|
|
704
|
-
| Business logic for one feature | Shared services (auth, email) |
|
|
705
|
-
| CRUD operations | Database schemas with migrations |
|
|
706
|
-
| Route handlers with context | Middleware, cron jobs, events |
|
|
707
|
-
|
|
708
|
-
---
|
|
709
|
-
|
|
710
|
-
### Route Handler Types
|
|
711
|
-
|
|
712
|
-
**Use the right handler for each use case:**
|
|
713
|
-
|
|
714
|
-
| Handler | Use Case | Input | Output |
|
|
715
|
-
|---------|----------|-------|--------|
|
|
716
|
-
| `.typed()` | Standard JSON APIs | Zod JSON | Zod JSON |
|
|
717
|
-
| `.stream()` | File downloads, video/images | Zod | Response (binary) |
|
|
718
|
-
| `.sse()` | Real-time notifications | Zod | SSE connection |
|
|
719
|
-
| `.formData()` | File uploads | Zod fields + files | Zod JSON |
|
|
720
|
-
| `.html()` | htmx, server components | Zod | HTML string |
|
|
721
|
-
| `.raw()` | Proxies, WebSockets | Request | Response |
|
|
722
|
-
|
|
723
|
-
```ts
|
|
724
|
-
// Stream handler - for file downloads, video, images
|
|
725
|
-
router.route("files.download").stream({
|
|
726
|
-
input: z.object({ fileId: z.string() }),
|
|
727
|
-
handle: async (input, ctx) => {
|
|
728
|
-
const file = await ctx.plugins.storage.getFile(input.fileId);
|
|
729
|
-
return new Response(file.stream, {
|
|
730
|
-
headers: { "Content-Type": file.mimeType },
|
|
731
|
-
});
|
|
732
|
-
},
|
|
733
|
-
});
|
|
734
|
-
|
|
735
|
-
// SSE handler - for real-time updates with typed events
|
|
736
|
-
router.route("notifications.subscribe").sse({
|
|
737
|
-
input: z.object({ userId: z.string() }),
|
|
738
|
-
events: {
|
|
739
|
-
notification: z.object({ message: z.string(), id: z.string() }),
|
|
740
|
-
alert: z.object({ level: z.string(), text: z.string() }),
|
|
741
|
-
},
|
|
742
|
-
handle: (input, ctx) => {
|
|
743
|
-
// Return channel names to subscribe to
|
|
744
|
-
return [`user:${input.userId}`, "global"];
|
|
745
|
-
},
|
|
746
|
-
});
|
|
747
|
-
|
|
748
|
-
// FormData handler - for file uploads
|
|
749
|
-
router.route("files.upload").formData({
|
|
750
|
-
input: z.object({ folder: z.string() }),
|
|
751
|
-
files: { maxSize: 10 * 1024 * 1024, accept: ["image/*"] },
|
|
752
|
-
handle: async ({ fields, files }, ctx) => {
|
|
753
|
-
const ids = await Promise.all(
|
|
754
|
-
files.map((f) => ctx.plugins.storage.save(f, fields.folder))
|
|
755
|
-
);
|
|
756
|
-
return { ids };
|
|
757
|
-
},
|
|
758
|
-
});
|
|
759
|
-
|
|
760
|
-
// HTML handler - for htmx partials
|
|
761
|
-
router.route("partials.userCard").html({
|
|
762
|
-
input: z.object({ userId: z.string() }),
|
|
763
|
-
handle: async (input, ctx) => {
|
|
764
|
-
const user = await ctx.plugins.users.getById(input.userId);
|
|
765
|
-
return `<div class="card">${user.name}</div>`;
|
|
766
|
-
},
|
|
767
|
-
});
|
|
768
|
-
```
|
|
769
|
-
|
|
770
|
-
**Using stream routes in Svelte:**
|
|
771
|
-
```svelte
|
|
772
|
-
<script lang="ts">
|
|
773
|
-
const api = createApi();
|
|
774
|
-
</script>
|
|
775
|
-
|
|
776
|
-
<!-- Use .url() for browser src attributes -->
|
|
777
|
-
<video src={api.files.download.url({ fileId: "video-123" })} controls />
|
|
778
|
-
<img src={api.images.thumbnail.url({ id: "img-456" })} />
|
|
779
|
-
<a href={api.files.download.url({ fileId: "doc-789" })} download>Download</a>
|
|
780
|
-
```
|
|
781
|
-
|
|
782
|
-
### 3. Register in Server
|
|
783
|
-
|
|
784
|
-
```ts
|
|
785
|
-
// src/server/index.ts
|
|
786
|
-
import { AppServer } from "@donkeylabs/server";
|
|
787
|
-
import { usersPlugin } from "./plugins/users";
|
|
788
|
-
import usersRoutes from "./routes/users";
|
|
789
|
-
|
|
790
|
-
export const server = new AppServer({
|
|
791
|
-
db,
|
|
792
|
-
port: 0,
|
|
793
|
-
generateTypes: { output: "./src/lib/api.ts" },
|
|
794
|
-
});
|
|
795
|
-
|
|
796
|
-
server.registerPlugin(usersPlugin);
|
|
797
|
-
server.use(usersRoutes);
|
|
798
|
-
server.handleGenerateMode();
|
|
799
|
-
```
|
|
800
|
-
|
|
801
|
-
### 4. Use in SvelteKit Page
|
|
802
|
-
|
|
803
|
-
```ts
|
|
804
|
-
// src/routes/users/+page.server.ts
|
|
805
|
-
import { createApi } from "$lib/api";
|
|
806
|
-
|
|
807
|
-
export const load = async ({ locals }) => {
|
|
808
|
-
const api = createApi({ locals }); // Direct call, no HTTP overhead
|
|
809
|
-
const users = await api.users.list({});
|
|
810
|
-
return { users };
|
|
811
|
-
};
|
|
812
|
-
```
|
|
813
|
-
|
|
814
|
-
```svelte
|
|
815
|
-
<!-- src/routes/users/+page.svelte -->
|
|
816
|
-
<script lang="ts">
|
|
817
|
-
import { Button } from "$lib/components/ui/button";
|
|
818
|
-
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
|
|
819
|
-
import { createApi } from "$lib/api";
|
|
820
|
-
|
|
821
|
-
let { data } = $props();
|
|
822
|
-
const api = createApi();
|
|
823
|
-
|
|
824
|
-
// Use $derived for SSR data (updates on navigation)
|
|
825
|
-
let users = $derived(data.users);
|
|
826
|
-
|
|
827
|
-
// For refresh, we need local state + watch pattern
|
|
828
|
-
let localUsers = $state<typeof data.users | null>(null);
|
|
829
|
-
let displayUsers = $derived(localUsers ?? users);
|
|
830
|
-
let loading = $state(false);
|
|
831
|
-
|
|
832
|
-
async function refresh() {
|
|
833
|
-
loading = true;
|
|
834
|
-
try {
|
|
835
|
-
localUsers = await api.users.list({});
|
|
836
|
-
} finally {
|
|
837
|
-
loading = false;
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
</script>
|
|
841
|
-
|
|
842
|
-
<Card>
|
|
843
|
-
<CardHeader>
|
|
844
|
-
<CardTitle>Users</CardTitle>
|
|
845
|
-
</CardHeader>
|
|
846
|
-
<CardContent>
|
|
847
|
-
{#each displayUsers as user}
|
|
848
|
-
<p>{user.name} - {user.email}</p>
|
|
849
|
-
{/each}
|
|
850
|
-
<Button onclick={refresh} disabled={loading}>
|
|
851
|
-
{loading ? "Loading..." : "Refresh"}
|
|
852
|
-
</Button>
|
|
853
|
-
</CardContent>
|
|
854
|
-
</Card>
|
|
855
|
-
```
|
|
856
|
-
|
|
857
|
-
---
|
|
858
|
-
|
|
859
|
-
## Middleware - Auth, Rate Limiting, etc.
|
|
860
|
-
|
|
861
|
-
**Apply middleware to routes for cross-cutting concerns:**
|
|
862
|
-
|
|
863
|
-
```ts
|
|
864
|
-
// Routes with middleware chain
|
|
865
|
-
const router = createRouter("api");
|
|
866
|
-
|
|
867
|
-
// Single middleware
|
|
868
|
-
router.middleware
|
|
869
|
-
.auth({ required: true })
|
|
870
|
-
.route("protected").typed({
|
|
871
|
-
handle: async (input, ctx) => {
|
|
872
|
-
// ctx.user is set by auth middleware
|
|
873
|
-
return { userId: ctx.user.id };
|
|
874
|
-
},
|
|
875
|
-
});
|
|
876
107
|
|
|
877
|
-
//
|
|
878
|
-
router.
|
|
879
|
-
.cors({ origin: "*" })
|
|
880
|
-
.auth({ required: true })
|
|
881
|
-
.rateLimit({ limit: 100, window: "1m" })
|
|
882
|
-
.route("admin").typed({
|
|
883
|
-
handle: async (input, ctx) => { ... },
|
|
884
|
-
});
|
|
885
|
-
|
|
886
|
-
// Reusable middleware chain
|
|
887
|
-
const protectedRoute = router.middleware
|
|
888
|
-
.auth({ required: true })
|
|
889
|
-
.rateLimit({ limit: 1000, window: "1h" });
|
|
890
|
-
|
|
891
|
-
protectedRoute.route("users.list").typed({ ... });
|
|
892
|
-
protectedRoute.route("users.create").typed({ ... });
|
|
108
|
+
// index.ts (thin router)
|
|
109
|
+
router.route("create").typed({ input, output, handle: CreateOrderHandler });
|
|
893
110
|
```
|
|
894
111
|
|
|
895
|
-
|
|
896
|
-
|
|
112
|
+
## API Client Usage
|
|
897
113
|
```ts
|
|
898
|
-
//
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
export const authPlugin = createPlugin.define({
|
|
902
|
-
name: "auth",
|
|
903
|
-
|
|
904
|
-
// Service MUST come before middleware
|
|
905
|
-
service: async (ctx) => ({
|
|
906
|
-
validateToken: async (token: string) => {
|
|
907
|
-
// Validation logic...
|
|
908
|
-
return { id: "user-123", role: "admin" };
|
|
909
|
-
},
|
|
910
|
-
}),
|
|
911
|
-
|
|
912
|
-
// Middleware can access its own service
|
|
913
|
-
middleware: (ctx, service) => ({
|
|
914
|
-
auth: createMiddleware<{ required?: boolean }>(
|
|
915
|
-
async (req, reqCtx, next, config) => {
|
|
916
|
-
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
917
|
-
|
|
918
|
-
if (!token && config?.required) {
|
|
919
|
-
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
if (token) {
|
|
923
|
-
reqCtx.user = await service.validateToken(token);
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
return next(); // Continue to handler
|
|
927
|
-
}
|
|
928
|
-
),
|
|
929
|
-
}),
|
|
930
|
-
});
|
|
931
|
-
```
|
|
932
|
-
|
|
933
|
-
---
|
|
934
|
-
|
|
935
|
-
## Testing
|
|
936
|
-
|
|
937
|
-
**Use the test harness for plugin testing:**
|
|
938
|
-
|
|
939
|
-
```ts
|
|
940
|
-
// src/server/plugins/users/tests/unit.test.ts
|
|
941
|
-
import { describe, test, expect, beforeEach } from "bun:test";
|
|
942
|
-
import { createTestHarness } from "@donkeylabs/server/harness";
|
|
943
|
-
import { usersPlugin } from "../index";
|
|
944
|
-
|
|
945
|
-
describe("usersPlugin", () => {
|
|
946
|
-
let users: ReturnType<typeof manager.getServices>["users"];
|
|
947
|
-
let db: Awaited<ReturnType<typeof createTestHarness>>["db"];
|
|
948
|
-
|
|
949
|
-
beforeEach(async () => {
|
|
950
|
-
// Fresh in-memory DB for each test
|
|
951
|
-
const harness = await createTestHarness(usersPlugin);
|
|
952
|
-
users = harness.manager.getServices().users;
|
|
953
|
-
db = harness.db;
|
|
954
|
-
});
|
|
955
|
-
|
|
956
|
-
test("create() inserts user", async () => {
|
|
957
|
-
const user = await users.create({ email: "test@example.com", name: "Test" });
|
|
958
|
-
|
|
959
|
-
expect(user.id).toBeDefined();
|
|
960
|
-
|
|
961
|
-
// Verify in database
|
|
962
|
-
const dbUser = await db
|
|
963
|
-
.selectFrom("users")
|
|
964
|
-
.where("id", "=", user.id)
|
|
965
|
-
.selectAll()
|
|
966
|
-
.executeTakeFirst();
|
|
967
|
-
|
|
968
|
-
expect(dbUser?.email).toBe("test@example.com");
|
|
969
|
-
});
|
|
970
|
-
});
|
|
971
|
-
```
|
|
972
|
-
|
|
973
|
-
**With plugin dependencies:**
|
|
974
|
-
|
|
975
|
-
```ts
|
|
976
|
-
import { ordersPlugin } from "../plugins/orders";
|
|
977
|
-
import { usersPlugin } from "../plugins/users";
|
|
978
|
-
|
|
979
|
-
// ordersPlugin depends on usersPlugin
|
|
980
|
-
const { manager } = await createTestHarness(ordersPlugin, [usersPlugin]);
|
|
981
|
-
|
|
982
|
-
const orders = manager.getServices().orders;
|
|
983
|
-
const users = manager.getServices().users;
|
|
984
|
-
```
|
|
985
|
-
|
|
986
|
-
---
|
|
987
|
-
|
|
988
|
-
## Generated API Client - Full Capabilities
|
|
989
|
-
|
|
990
|
-
**ALWAYS use `createApi()` from `$lib/api.ts`. NEVER use raw fetch/EventSource.**
|
|
991
|
-
|
|
992
|
-
### Basic API Calls
|
|
993
|
-
|
|
994
|
-
```ts
|
|
995
|
-
import { createApi } from "$lib/api";
|
|
114
|
+
// +page.server.ts (SSR - direct calls)
|
|
115
|
+
const api = createApi({ locals });
|
|
116
|
+
return { users: await api.users.list({}) };
|
|
996
117
|
|
|
118
|
+
// +page.svelte (browser - HTTP)
|
|
997
119
|
const api = createApi();
|
|
998
|
-
|
|
999
|
-
// All calls are typed - input and output
|
|
1000
|
-
const user = await api.users.get({ id: "123" });
|
|
1001
|
-
const result = await api.users.create({ email: "a@b.com", name: "Test" });
|
|
1002
|
-
```
|
|
1003
|
-
|
|
1004
|
-
### SSE (Server-Sent Events)
|
|
1005
|
-
|
|
1006
|
-
```ts
|
|
1007
|
-
// Subscribe to channels with auto-reconnect
|
|
1008
|
-
const unsubscribe = api.sse.subscribe(
|
|
1009
|
-
["notifications", "alerts"], // Channel names
|
|
1010
|
-
(eventType, data) => {
|
|
1011
|
-
// eventType: "cron-event", "job-completed", "manual", etc.
|
|
1012
|
-
// data: Already parsed JSON
|
|
1013
|
-
console.log(eventType, data);
|
|
1014
|
-
},
|
|
1015
|
-
{ reconnect: true } // Auto-reconnect on disconnect (default: true)
|
|
1016
|
-
);
|
|
1017
|
-
|
|
1018
|
-
// Cleanup in onMount return or when done
|
|
1019
|
-
onMount(() => {
|
|
1020
|
-
const unsub = api.sse.subscribe([...], callback);
|
|
1021
|
-
return () => unsub(); // Cleanup on unmount
|
|
1022
|
-
});
|
|
120
|
+
await api.users.create({ email, name });
|
|
1023
121
|
```
|
|
1024
122
|
|
|
1025
|
-
|
|
123
|
+
## Handler Types
|
|
124
|
+
| Handler | Use Case |
|
|
125
|
+
|---------|----------|
|
|
126
|
+
| `.typed()` | JSON APIs |
|
|
127
|
+
| `.stream()` | File downloads |
|
|
128
|
+
| `.sse()` | Real-time |
|
|
129
|
+
| `.formData()` | File uploads |
|
|
130
|
+
| `.html()` | htmx partials |
|
|
1026
131
|
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
const result = await api.files.upload(
|
|
1030
|
-
{ folder: "avatars", userId: "123" }, // Typed fields
|
|
1031
|
-
[selectedFile] // File objects
|
|
1032
|
-
);
|
|
1033
|
-
```
|
|
1034
|
-
|
|
1035
|
-
### Streaming Responses
|
|
1036
|
-
|
|
1037
|
-
```ts
|
|
1038
|
-
// For routes that return streams (video, large files, etc.)
|
|
1039
|
-
const response = await api.media.stream({ videoId: "abc" });
|
|
1040
|
-
// response is raw Response - handle as needed
|
|
1041
|
-
|
|
1042
|
-
// Or get URL for <video>, <img>, <a download>
|
|
1043
|
-
const videoUrl = api.media.streamUrl({ videoId: "abc" });
|
|
1044
|
-
// Use in: <video src={videoUrl}>
|
|
1045
|
-
```
|
|
1046
|
-
|
|
1047
|
-
### HTML Responses
|
|
1048
|
-
|
|
1049
|
-
```ts
|
|
1050
|
-
// For routes that return HTML
|
|
1051
|
-
const html = await api.reports.render({ reportId: "123" });
|
|
1052
|
-
// html is a string
|
|
1053
|
-
```
|
|
1054
|
-
|
|
1055
|
-
### SSR vs Browser - The Client Handles It
|
|
132
|
+
## Core Services (ctx.core)
|
|
133
|
+
`ctx.core.logger`, `ctx.core.cache`, `ctx.core.jobs`, `ctx.core.events`, `ctx.core.rateLimiter`, `ctx.core.sse`
|
|
1056
134
|
|
|
135
|
+
## Error Handling
|
|
1057
136
|
```ts
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
return { data: await api.users.list({}) };
|
|
1062
|
-
};
|
|
1063
|
-
|
|
1064
|
-
// +page.svelte - HTTP calls automatically
|
|
1065
|
-
const api = createApi(); // Uses fetch internally
|
|
137
|
+
throw ctx.errors.NotFound("User not found"); // 404
|
|
138
|
+
throw ctx.errors.BadRequest("Invalid input"); // 400
|
|
139
|
+
throw ctx.errors.Unauthorized("Login required"); // 401
|
|
1066
140
|
```
|
|
1067
141
|
|
|
1068
|
-
---
|
|
1069
|
-
|
|
1070
|
-
## Database Queries with Kysely
|
|
1071
|
-
|
|
1072
|
-
**Always use Kysely query builder, never raw SQL:**
|
|
1073
|
-
|
|
1074
|
-
```ts
|
|
1075
|
-
// ✅ SELECT
|
|
1076
|
-
const user = await ctx.db
|
|
1077
|
-
.selectFrom("users")
|
|
1078
|
-
.where("id", "=", id)
|
|
1079
|
-
.selectAll()
|
|
1080
|
-
.executeTakeFirst();
|
|
1081
|
-
|
|
1082
|
-
// ✅ INSERT
|
|
1083
|
-
await ctx.db
|
|
1084
|
-
.insertInto("users")
|
|
1085
|
-
.values({ id, email, name })
|
|
1086
|
-
.execute();
|
|
1087
|
-
|
|
1088
|
-
// ✅ UPDATE
|
|
1089
|
-
await ctx.db
|
|
1090
|
-
.updateTable("users")
|
|
1091
|
-
.set({ name: newName })
|
|
1092
|
-
.where("id", "=", id)
|
|
1093
|
-
.execute();
|
|
1094
|
-
|
|
1095
|
-
// ✅ DELETE
|
|
1096
|
-
await ctx.db
|
|
1097
|
-
.deleteFrom("users")
|
|
1098
|
-
.where("id", "=", id)
|
|
1099
|
-
.execute();
|
|
1100
|
-
|
|
1101
|
-
// ✅ JOIN
|
|
1102
|
-
const orders = await ctx.db
|
|
1103
|
-
.selectFrom("orders")
|
|
1104
|
-
.innerJoin("users", "users.id", "orders.user_id")
|
|
1105
|
-
.select(["orders.id", "orders.total", "users.name"])
|
|
1106
|
-
.execute();
|
|
1107
|
-
```
|
|
1108
|
-
|
|
1109
|
-
---
|
|
1110
|
-
|
|
1111
142
|
## Commands
|
|
1112
|
-
|
|
1113
143
|
```sh
|
|
1114
|
-
bun run dev
|
|
1115
|
-
|
|
1116
|
-
bun
|
|
1117
|
-
bun --bun tsc --noEmit # Type check
|
|
1118
|
-
|
|
1119
|
-
# After adding plugins/routes/migrations:
|
|
1120
|
-
bunx donkeylabs generate # Regenerate types
|
|
144
|
+
bun run dev # Dev server
|
|
145
|
+
bunx donkeylabs generate # Regen types after changes
|
|
146
|
+
bun --bun tsc --noEmit # Type check
|
|
1121
147
|
```
|
|
1122
148
|
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
## MCP Tools Available
|
|
1126
|
-
|
|
1127
|
-
| Tool | Description |
|
|
1128
|
-
|------|-------------|
|
|
1129
|
-
| `get_project_info` | Get project structure overview |
|
|
1130
|
-
| `create_plugin` | Create a new plugin with correct structure |
|
|
1131
|
-
| `add_migration` | Create a Kysely migration file |
|
|
1132
|
-
| `add_service_method` | Add method to plugin service |
|
|
1133
|
-
| `create_router` | Create a new route file |
|
|
1134
|
-
| `add_route` | Add route to existing router |
|
|
1135
|
-
| `generate_types` | Regenerate TypeScript types |
|
|
1136
|
-
| `list_plugins` | List all plugins and methods |
|
|
1137
|
-
|
|
1138
|
-
---
|
|
1139
|
-
|
|
1140
|
-
## Detailed Documentation
|
|
1141
|
-
|
|
1142
|
-
**For advanced topics, read the corresponding file in `docs/`:**
|
|
1143
|
-
|
|
1144
|
-
| Topic | File | When to Read |
|
|
1145
|
-
|-------|------|--------------|
|
|
1146
|
-
| All handler types | `docs/handlers.md` | Creating stream, SSE, formData, HTML, or raw routes |
|
|
1147
|
-
| Middleware | `docs/middleware.md` | Creating custom auth, rate limiting, CORS |
|
|
1148
|
-
| Database & Migrations | `docs/database.md` | Complex Kysely queries, transactions, joins |
|
|
1149
|
-
| Plugins | `docs/plugins.md` | Plugin lifecycle, dependencies, init hooks |
|
|
1150
|
-
| Testing | `docs/testing.md` | Test harness, mocking, integration tests |
|
|
1151
|
-
| Background Jobs | `docs/jobs.md` | Async job processing, retries |
|
|
1152
|
-
| Cron Tasks | `docs/cron.md` | Scheduled tasks |
|
|
1153
|
-
| SSE | `docs/sse.md` | Server-sent events, broadcasting |
|
|
1154
|
-
| Workflows | `docs/workflows.md` | Step functions, parallel execution, state machines |
|
|
1155
|
-
| Router | `docs/router.md` | Route definitions, prefixes, nesting |
|
|
1156
|
-
| Errors | `docs/errors.md` | Custom error types, error handling |
|
|
1157
|
-
| SvelteKit Adapter | `docs/sveltekit-adapter.md` | Hooks, SSR integration, API client |
|
|
1158
|
-
|
|
1159
|
-
---
|
|
1160
|
-
|
|
1161
|
-
## Key Reminders
|
|
149
|
+
## MCP Tools
|
|
150
|
+
`get_project_info`, `create_plugin`, `add_migration`, `add_service_method`, `create_router`, `add_route`, `generate_types`, `list_plugins`, `scaffold_feature`
|
|
1162
151
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
3. **shadcn-svelte**: Only UI library for components
|
|
1166
|
-
4. **No $effect**: Use `watch` from **runed** for reactive effects, `onMount` for lifecycle
|
|
1167
|
-
5. **$derived for props**: Never `$state(data.x)` - use `$derived(data.x)` for reactive props
|
|
1168
|
-
6. **Loading states**: Always track loading/error states for async operations
|
|
1169
|
-
7. **Thin Routes**: Keep handlers thin, business logic in plugins
|
|
1170
|
-
8. **ctx.errors**: Use `ctx.errors.NotFound()`, etc. for proper error responses
|
|
1171
|
-
9. **Number migrations**: Always prefix with 001_, 002_, etc.
|
|
1172
|
-
10. **Generate Types**: Run `bunx donkeylabs generate` after any plugin/route/migration changes
|
|
1173
|
-
11. **SSR vs Browser**: Pass `{ locals }` in +page.server.ts, nothing in +page.svelte
|
|
1174
|
-
12. **Never raw fetch**: ALWAYS use `createApi()` client - never `fetch()` or `new EventSource()`
|
|
1175
|
-
13. **Right handler type**: Use `.typed()` for JSON, `.stream()` for files, `.sse()` for real-time, `.formData()` for uploads
|
|
1176
|
-
14. **Auth via middleware**: Use `router.middleware.auth({ required: true })` for protected routes
|
|
1177
|
-
15. **Test with harness**: Use `createTestHarness(plugin)` for isolated in-memory testing
|
|
152
|
+
## Detailed Docs
|
|
153
|
+
See `docs/` for: handlers, middleware, database, plugins, testing, jobs, cron, sse, workflows, router, errors, sveltekit-adapter.
|