@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.
Files changed (2) hide show
  1. package/CLAUDE.md +84 -1108
  2. package/package.json +1 -1
package/CLAUDE.md CHANGED
@@ -6,1172 +6,148 @@ alwaysApply: true
6
6
 
7
7
  # DonkeyLabs Project
8
8
 
9
- This is a **SvelteKit + @donkeylabs/server** full-stack application with type-safe APIs, database plugins, and Svelte 5 frontend.
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
- When the `donkeylabs` MCP server is available, **ALWAYS use MCP tools** instead of writing code manually:
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
- // CORRECT - Kysely schema builder
33
- import type { Kysely } from "kysely";
34
-
35
- export async function up(db: Kysely<any>): Promise<void> {
36
- await db.schema
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
- export async function up(db: Kysely<any>): Promise<void> {
63
- await sql`CREATE TABLE users (id TEXT PRIMARY KEY)`.execute(db); // NO!
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 total = $derived(items.length);
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
- <!-- CORRECT - onclick not on:click -->
150
- <button onclick={handleClick}>Count: {count}</button>
151
- ```
40
+ // Use watch for effects, NEVER $effect
41
+ watch(() => count, (val) => console.log(val));
152
42
 
153
- ```svelte
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
- ## Plugin Dependencies
455
-
456
- Access other plugins via `ctx.plugins`:
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: string) => {
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
- ### 2. Create Routes (API Endpoints)
528
-
90
+ ## Route Quick Reference
529
91
  ```ts
530
- // src/server/routes/users.ts
531
- import { createRouter, defineRoute } from "@donkeylabs/server";
532
- import { z } from "zod";
533
-
534
- const users = createRouter("users");
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
- ### Handler Class (Business Logic Lives Here)
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
- // Chained middleware (executes left to right)
878
- router.middleware
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
- **Creating custom middleware in a plugin:**
896
-
112
+ ## API Client Usage
897
113
  ```ts
898
- // src/server/plugins/auth/index.ts
899
- import { createPlugin, createMiddleware } from "@donkeylabs/server";
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
- ### File Uploads (FormData)
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
- ```ts
1028
- // If a route uses .formData() handler
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
- // +page.server.ts - Direct calls, no HTTP
1059
- export const load = async ({ locals }) => {
1060
- const api = createApi({ locals }); // Uses locals.handleRoute
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 # Start development server
1115
- bun run build # Build for production
1116
- bun test # Run tests
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
- 1. **MCP First**: Always use MCP tools when available
1164
- 2. **Kysely Only**: Never raw SQL in migrations or queries
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "1.1.15",
3
+ "version": "1.1.16",
4
4
  "type": "module",
5
5
  "description": "Type-safe plugin system for building RPC-style APIs with Bun",
6
6
  "main": "./src/index.ts",