@donkeylabs/server 1.1.11 → 1.1.13

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 (3) hide show
  1. package/CLAUDE.md +1029 -307
  2. package/docs/QUICKSTART.md +149 -0
  3. package/package.json +1 -1
package/CLAUDE.md CHANGED
@@ -1,313 +1,932 @@
1
1
  ---
2
- description: Plugin system for Bun with type-safe handlers, core services, and auto-generated registries.
3
- globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
4
- alwaysApply: false
2
+ description: DonkeyLabs SvelteKit project with type-safe API, plugins, and Svelte 5.
3
+ globs: "*.ts, *.tsx, *.svelte, *.html, *.css, *.js, *.jsx, package.json"
4
+ alwaysApply: true
5
5
  ---
6
6
 
7
- # @donkeylabs/server
7
+ # DonkeyLabs Project
8
8
 
9
- A **type-safe plugin system** for building RPC-style APIs with Bun. Features automatic dependency resolution, database schema merging, custom handlers, middleware, and built-in core services.
9
+ This is a **SvelteKit + @donkeylabs/server** full-stack application with type-safe APIs, database plugins, and Svelte 5 frontend.
10
10
 
11
11
  ---
12
12
 
13
- ## AI Assistant Instructions
14
-
15
- **IMPORTANT: Follow these guidelines when working with this codebase.**
13
+ ## CRITICAL RULES
16
14
 
17
15
  ### 1. Use MCP Tools First
18
16
 
19
- When the `donkeylabs` MCP server is available, **always use MCP tools** instead of writing code manually:
17
+ When the `donkeylabs` MCP server is available, **ALWAYS use MCP tools** instead of writing code manually:
20
18
 
21
- | Task | Use MCP Tool |
22
- |------|--------------|
19
+ | Task | MCP Tool |
20
+ |------|----------|
23
21
  | Create a plugin | `create_plugin` |
24
22
  | Add a route | `add_route` |
25
23
  | Add database migration | `add_migration` |
26
24
  | Add service method | `add_service_method` |
27
25
  | Generate types | `generate_types` |
28
26
 
29
- MCP tools ensure correct file structure, naming conventions, and patterns.
27
+ ### 2. Database Migrations - KYSELY ONLY
28
+
29
+ **CRITICAL: Migrations MUST use Kysely schema builder. NEVER use raw SQL.**
30
+
31
+ ```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";
30
61
 
31
- ### 2. Read Docs Before Implementing
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
+ }
66
+ ```
32
67
 
33
- Before implementing any feature, **read the relevant documentation**:
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
+ ### 3. Frontend - Svelte 5 & shadcn-svelte ONLY
75
+
76
+ **UI Components:** Use **shadcn-svelte** exclusively. Never use other UI libraries.
77
+
78
+ ```svelte
79
+ <!-- ✅ CORRECT - shadcn-svelte components -->
80
+ <script lang="ts">
81
+ 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
+ ```
34
96
 
35
- | Feature | Read First |
36
- |---------|------------|
37
- | Testing | [docs/testing.md](docs/testing.md) - Test harness, unit & integration tests |
38
- | Database queries | [docs/database.md](docs/database.md) - Use Kysely, NOT raw SQL |
39
- | Creating plugins | [docs/plugins.md](docs/plugins.md) - Includes plugin vs route decision |
40
- | Adding routes | [docs/router.md](docs/router.md) |
41
- | Migrations | [docs/database.md](docs/database.md) - Use Kysely schema builder |
42
- | Middleware | [docs/middleware.md](docs/middleware.md) |
43
- | Background jobs | [docs/jobs.md](docs/jobs.md) |
44
- | Cron tasks | [docs/cron.md](docs/cron.md) |
97
+ **Svelte 5 Patterns - NEVER use $effect, use `watch` from runed:**
45
98
 
46
- ### 3. Key Patterns to Follow
99
+ ```svelte
100
+ <!-- ✅ CORRECT - Svelte 5 runes with runed -->
101
+ <script lang="ts">
102
+ import { onMount } from "svelte";
103
+ import { watch } from "runed";
47
104
 
48
- - **Plugins vs Routes**: Plugins = reusable business logic; Routes = API endpoints. See [docs/plugins.md](docs/plugins.md)
49
- - **Kysely for DB**: Always use Kysely query builder, never raw SQL. See [docs/database.md](docs/database.md)
50
- - **Migrations**: Use TypeScript migrations with Kysely schema builder (NOT `sql` tagged templates)
51
- - **Type generation**: Run `donkeylabs generate` after adding plugins/migrations
52
- - **Thin routes**: Keep route handlers thin; delegate business logic to plugin services
105
+ // Props
106
+ let { data } = $props();
53
107
 
54
- ### 4. Write Tests
108
+ // Reactive state
109
+ let count = $state(0);
110
+ let items = $state<string[]>([]);
55
111
 
56
- **REQUIRED: Write tests for new functionality.** See [docs/testing.md](docs/testing.md)
112
+ // Derived values
113
+ let doubled = $derived(count * 2);
114
+ let total = $derived(items.length);
57
115
 
58
- ```ts
59
- import { createTestHarness } from "@donkeylabs/server/harness";
60
- import { myPlugin } from "./plugins/myPlugin";
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
+ );
61
124
 
62
- const { manager, db, core } = await createTestHarness(myPlugin);
63
- const service = manager.getServices().myPlugin;
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>
148
+
149
+ <!-- ✅ CORRECT - onclick not on:click -->
150
+ <button onclick={handleClick}>Count: {count}</button>
64
151
  ```
65
152
 
66
- - **Unit tests**: Test plugin service methods in isolation
67
- - **Integration tests**: Test plugins working together
68
- - **Place tests next to code**: `plugins/users/tests/unit.test.ts`
153
+ ```svelte
154
+ <!-- WRONG - Never use $effect -->
155
+ <script lang="ts">
156
+ let count = $state(0);
69
157
 
70
- ### 5. Verify Before Committing
158
+ // NEVER DO THIS - use watch from runed instead
159
+ $effect(() => {
160
+ console.log(count); // NO!
161
+ });
162
+ </script>
163
+ ```
71
164
 
72
- **REQUIRED: Always run these checks before finishing:**
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
73
173
 
74
- ```sh
75
- # 1. Type check - catch type errors
76
- bun --bun tsc --noEmit
174
+ ---
77
175
 
78
- # 2. Run tests - ensure nothing is broken
79
- bun test
176
+ ## Project Structure
80
177
 
81
- # 3. Generate types - if you added plugins/migrations
82
- donkeylabs generate
83
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
+ ---
84
213
 
85
- **Do NOT skip these steps.** Type errors and failing tests must be fixed before completion.
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
+ ```
86
337
 
87
338
  ---
88
339
 
89
- ## Bun-First Development
340
+ ## Schema Type Definitions
90
341
 
91
- Always use Bun instead of Node.js:
342
+ **Define table types alongside your migrations:**
92
343
 
93
- ```sh
94
- bun <file> # Instead of node/ts-node
95
- bun test # Instead of jest/vitest
96
- bun install # Instead of npm/yarn/pnpm install
97
- bun run <script> # Instead of npm run
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
+ }),
98
407
  ```
99
408
 
100
- Bun automatically loads `.env` - don't use dotenv.
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
+ ```
101
419
 
102
420
  ---
103
421
 
104
- ## Package Structure
105
-
106
- ```
107
- @donkeylabs/server/
108
- ├── src/ # Library source code
109
- │ ├── index.ts # Main exports
110
- │ ├── core.ts # Plugin system, PluginManager, type helpers
111
- │ ├── router.ts # Route builder, handler registry
112
- │ ├── handlers.ts # TypedHandler, RawHandler, createHandler
113
- │ ├── middleware.ts # Middleware system
114
- │ ├── server.ts # AppServer, HTTP handling, core services init
115
- │ ├── harness.ts # Test harness with in-memory DB
116
- │ ├── client/ # API client base
117
- │ │ └── base.ts # Client base class
118
- │ └── core/ # Core services
119
- │ ├── index.ts # Re-exports all services
120
- │ ├── logger.ts # Logger service
121
- │ ├── cache.ts # Cache service
122
- │ ├── events.ts # Events service
123
- │ ├── cron.ts # Cron service
124
- │ ├── jobs.ts # Jobs service
125
- │ ├── sse.ts # SSE service
126
- │ ├── rate-limiter.ts # Rate limiter service
127
- │ └── errors.ts # Error factories
128
- ├── cli/ # CLI commands
129
- │ ├── index.ts # CLI entry point (donkeylabs command)
130
- │ └── commands/
131
- │ ├── init.ts # Project scaffolding
132
- │ ├── generate.ts # Type generation
133
- │ └── plugin.ts # Plugin creation
134
- ├── templates/ # Templates for init and plugin commands
135
- │ ├── init/ # New project templates
136
- │ └── plugin/ # Plugin scaffolding templates
137
- ├── examples/ # Example projects
138
- │ └── starter/ # Complete starter template
139
- │ ├── src/index.ts
140
- │ ├── src/plugins/ # Example plugins (stats with middleware)
141
- │ ├── src/routes/ # Example routes with typing
142
- │ └── donkeylabs.config.ts
143
- ├── scripts/ # Build and generation scripts
144
- ├── test/ # Test files
145
- ├── registry.d.ts # Auto-generated plugin/handler registry
146
- └── context.d.ts # Auto-generated GlobalContext type
147
- ```
148
-
149
- ### Generated Files (DO NOT EDIT)
150
-
151
- - `registry.d.ts` - Plugin and handler type registry
152
- - `context.d.ts` - Server context with merged schemas
153
- - `.@donkeylabs/server/` - Generated types in user projects (gitignored)
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
+ ```
154
451
 
155
452
  ---
156
453
 
157
- ## User Project Structure
454
+ ## Plugin Dependencies
158
455
 
159
- After running `donkeylabs init`:
456
+ Access other plugins via `ctx.plugins`:
160
457
 
458
+ ```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
+ });
161
475
  ```
162
- my-project/
163
- ├── src/
164
- │ ├── index.ts # Server entry point
165
- │ └── plugins/ # Your plugins
166
- │ └── myPlugin/
167
- │ ├── index.ts # Plugin definition
168
- │ ├── schema.ts # Generated DB types
169
- │ └── migrations/ # SQL migrations
170
- ├── .@donkeylabs/server/ # Generated types (gitignored)
171
- │ ├── registry.d.ts
172
- │ └── context.d.ts
173
- ├── donkeylabs.config.ts # Configuration file
174
- ├── package.json
175
- └── tsconfig.json
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
176
491
  ```
177
492
 
178
493
  ---
179
494
 
180
- ## Quick Start
495
+ ## Creating Features
181
496
 
182
- ### 1. Create a Plugin
497
+ ### 1. Create a Plugin (Business Logic)
183
498
 
184
499
  ```ts
185
- // src/plugins/myPlugin/index.ts
500
+ // src/server/plugins/users/index.ts
186
501
  import { createPlugin } from "@donkeylabs/server";
187
502
 
188
- export const myPlugin = createPlugin.define({
189
- name: "myPlugin",
190
- service: async (ctx) => ({
191
- greet: (name: string) => `Hello, ${name}!`
192
- })
193
- });
503
+ export const usersPlugin = createPlugin
504
+ .withSchema<{ users: UsersTable }>()
505
+ .define({
506
+ name: "users",
507
+ 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
+ },
523
+ }),
524
+ });
194
525
  ```
195
526
 
196
- ### 2. Create Routes
527
+ ### 2. Create Routes (API Endpoints)
197
528
 
198
529
  ```ts
199
- // src/index.ts
200
- import { createRouter } from "@donkeylabs/server";
530
+ // src/server/routes/users.ts
531
+ import { createRouter, defineRoute } from "@donkeylabs/server";
201
532
  import { z } from "zod";
202
533
 
203
- const router = createRouter("api")
204
- .route("greet").typed({
205
- input: z.object({ name: z.string() }),
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(),
206
544
  handle: async (input, ctx) => {
207
- return { message: ctx.plugins.myPlugin.greet(input.name) };
208
- }
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
586
+ ```
587
+
588
+ ### Handler Class (Business Logic Lives Here)
589
+
590
+ ```ts
591
+ // handlers/create.handler.ts
592
+ import type { Handler, Routes, AppContext } from "$server/api";
593
+
594
+ export class CreateOrderHandler implements Handler<Routes.Orders.Create> {
595
+ 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
+ }
619
+ }
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,
209
643
  });
210
644
  ```
211
645
 
212
- ### 3. Start Server
646
+ ### Schemas (Validation + Types)
213
647
 
214
648
  ```ts
215
- // src/index.ts
216
- import { AppServer } from "@donkeylabs/server";
217
- import { myPlugin } from "./plugins/myPlugin";
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
+ });
218
659
 
219
- const server = new AppServer({
220
- db: createDatabase(),
221
- port: 3000,
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(),
222
665
  });
223
666
 
224
- server.registerPlugin(myPlugin);
225
- server.use(router);
226
- await server.start();
667
+ export type CreateOrderInput = z.infer<typeof createOrderSchema>;
668
+ export type Order = z.infer<typeof orderSchema>;
227
669
  ```
228
670
 
229
- ### 4. Make Requests
671
+ ### Testing Handlers Directly
230
672
 
231
- ```sh
232
- curl -X POST http://localhost:3000/api.greet \
233
- -H "Content-Type: application/json" \
234
- -d '{"name": "World"}'
235
- # {"message": "Hello, World!"}
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
+ });
236
697
  ```
237
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
+
238
708
  ---
239
709
 
240
- ## CLI Commands
710
+ ### Route Handler Types
241
711
 
242
- ```sh
243
- donkeylabs # Interactive menu (context-aware)
244
- donkeylabs init # Create new project
245
- donkeylabs generate # Generate types from plugins
246
- donkeylabs plugin create # Interactive plugin creation
247
- ```
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 |
248
722
 
249
- ### Interactive Mode
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
+ });
250
734
 
251
- Running `donkeylabs` with no arguments launches an interactive menu:
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
+ });
252
747
 
253
- **From project root:**
254
- - Create New Plugin
255
- - Initialize New Project
256
- - Generate Types
257
- - Generate Registry
258
- - Generate Server Context
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
+ });
259
759
 
260
- **From inside a plugin directory (`src/plugins/<name>/`):**
261
- - Generate Schema Types
262
- - Create Migration
263
- - Back to Global Menu
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
+ ```
264
769
 
265
- ### Development Commands
770
+ **Using stream routes in Svelte:**
771
+ ```svelte
772
+ <script lang="ts">
773
+ const api = createApi();
774
+ </script>
266
775
 
267
- ```sh
268
- bun run gen:registry # Regenerate registry.d.ts
269
- bun run gen:server # Regenerate context.d.ts
270
- bun run cli # Interactive CLI
271
- bun test # Run all tests
272
- bun --bun tsc --noEmit # Type check
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>
273
780
  ```
274
781
 
275
- ---
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";
276
789
 
277
- ## Server Context
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
+ ```
278
800
 
279
- Every route handler receives `ServerContext`:
801
+ ### 4. Use in SvelteKit Page
280
802
 
281
803
  ```ts
282
- interface ServerContext {
283
- db: Kysely<MergedSchema>; // Database with all plugin schemas
284
- plugins: { // All plugin services
285
- myPlugin: MyPluginService;
286
- auth: AuthService;
287
- // ... auto-generated
288
- };
289
- core: CoreServices; // Logger, cache, events, etc.
290
- errors: Errors; // Error factories (BadRequest, NotFound, etc.)
291
- ip: string; // Client IP address
292
- requestId: string; // Unique request ID
293
- user?: any; // Set by auth middleware
294
- }
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>
295
855
  ```
296
856
 
297
857
  ---
298
858
 
299
- ## Configuration File
859
+ ## Middleware - Auth, Rate Limiting, etc.
860
+
861
+ **Apply middleware to routes for cross-cutting concerns:**
300
862
 
301
863
  ```ts
302
- // donkeylabs.config.ts
303
- import { defineConfig } from "@donkeylabs/server";
304
-
305
- export default defineConfig({
306
- plugins: ["./src/plugins/**/index.ts"], // Plugin glob patterns
307
- outDir: ".@donkeylabs/server", // Generated types directory
308
- client: { // Optional client generation
309
- output: "./src/client/api.ts",
310
- },
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
+
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({ ... });
893
+ ```
894
+
895
+ **Creating custom middleware in a plugin:**
896
+
897
+ ```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
+ }),
311
930
  });
312
931
  ```
313
932
 
@@ -315,141 +934,244 @@ export default defineConfig({
315
934
 
316
935
  ## Testing
317
936
 
937
+ **Use the test harness for plugin testing:**
938
+
318
939
  ```ts
940
+ // src/server/plugins/users/tests/unit.test.ts
941
+ import { describe, test, expect, beforeEach } from "bun:test";
319
942
  import { createTestHarness } from "@donkeylabs/server/harness";
320
- import { myPlugin } from "./plugins/myPlugin";
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
+ ```
321
972
 
322
- const { manager, db, core } = await createTestHarness(myPlugin);
973
+ **With plugin dependencies:**
323
974
 
324
- // Test with real in-memory SQLite + all core services
325
- const service = manager.getServices().myPlugin;
326
- expect(service.greet("Test")).toBe("Hello, Test!");
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;
327
984
  ```
328
985
 
329
986
  ---
330
987
 
331
- ## Package Exports
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
332
993
 
333
994
  ```ts
334
- // Main exports
335
- import { createPlugin, AppServer, createRouter } from "@donkeylabs/server";
995
+ import { createApi } from "$lib/api";
336
996
 
337
- // Client base class
338
- import { RpcClient } from "@donkeylabs/server/client";
997
+ const api = createApi();
339
998
 
340
- // Test harness
341
- import { createTestHarness } from "@donkeylabs/server/harness";
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" });
342
1002
  ```
343
1003
 
344
- ---
1004
+ ### SSE (Server-Sent Events)
345
1005
 
346
- ## Common Issues
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
+ });
1023
+ ```
347
1024
 
348
- ### Handler autocomplete not working
349
- 1. Run `donkeylabs generate` or `bun run gen:registry`
350
- 2. Restart TypeScript language server (Cmd+Shift+P > "Restart TS Server")
1025
+ ### File Uploads (FormData)
351
1026
 
352
- ### Plugin types not recognized
353
- 1. Ensure `.@donkeylabs/server` is in your tsconfig's `include` array
354
- 2. Run `donkeylabs generate`
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
+ ```
355
1034
 
356
- ### ctx.plugins shows as `any`
357
- 1. Make sure `service` comes BEFORE `middleware` in plugin definition
358
- 2. Run `donkeylabs generate` to regenerate types
359
- 3. Restart TypeScript language server
1035
+ ### Streaming Responses
360
1036
 
361
- ### Core services undefined
362
- 1. Check `ServerConfig` has required `db` property
363
- 2. Core services are auto-initialized in `AppServer` constructor
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
364
1041
 
365
- ---
1042
+ // Or get URL for <video>, <img>, <a download>
1043
+ const videoUrl = api.media.streamUrl({ videoId: "abc" });
1044
+ // Use in: <video src={videoUrl}>
1045
+ ```
366
1046
 
367
- ## Bun APIs
1047
+ ### HTML Responses
368
1048
 
369
- Use Bun's built-in APIs instead of npm packages:
1049
+ ```ts
1050
+ // For routes that return HTML
1051
+ const html = await api.reports.render({ reportId: "123" });
1052
+ // html is a string
1053
+ ```
370
1054
 
371
- | Use | Instead of |
372
- |-----|------------|
373
- | `Bun.serve()` | express, fastify |
374
- | `bun:sqlite` | better-sqlite3 |
375
- | `Bun.redis` | ioredis |
376
- | `Bun.sql` | pg, postgres.js |
377
- | `WebSocket` | ws |
378
- | `Bun.file()` | fs.readFile |
379
- | `Bun.$\`cmd\`` | execa |
1055
+ ### SSR vs Browser - The Client Handles It
380
1056
 
381
- See `node_modules/bun-types/docs/**.md` for full API documentation.
1057
+ ```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
1066
+ ```
382
1067
 
383
1068
  ---
384
1069
 
385
- ## Documentation
386
-
387
- Detailed documentation is available in the `docs/` directory:
388
-
389
- | Document | Description |
390
- |----------|-------------|
391
- | [testing.md](docs/testing.md) | Test harness, unit tests, integration tests, mocking |
392
- | [database.md](docs/database.md) | Kysely queries, CRUD operations, joins, transactions, migrations |
393
- | [plugins.md](docs/plugins.md) | Creating plugins, schemas, dependencies, middleware, and init hooks |
394
- | [router.md](docs/router.md) | Routes, handlers, input/output validation, middleware chains |
395
- | [middleware.md](docs/middleware.md) | Creating and using middleware with typed configuration |
396
- | [handlers.md](docs/handlers.md) | Custom handlers (typed, raw, plugin handlers) |
397
- | [core-services.md](docs/core-services.md) | Overview of all core services |
398
- | [logger.md](docs/logger.md) | Structured logging with child loggers |
399
- | [cache.md](docs/cache.md) | In-memory caching with TTL |
400
- | [events.md](docs/events.md) | Pub/sub event system |
401
- | [cron.md](docs/cron.md) | Scheduled tasks |
402
- | [jobs.md](docs/jobs.md) | Background job queue |
403
- | [sse.md](docs/sse.md) | Server-sent events |
404
- | [rate-limiter.md](docs/rate-limiter.md) | Request rate limiting |
405
- | [errors.md](docs/errors.md) | Error factories and custom errors |
406
- | [api-client.md](docs/api-client.md) | Generated API client usage |
407
- | [project-structure.md](docs/project-structure.md) | Recommended project organization |
408
- | [cli.md](docs/cli.md) | CLI commands and interactive mode |
409
- | [sveltekit-adapter.md](docs/sveltekit-adapter.md) | SvelteKit adapter integration |
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
+ ```
410
1108
 
411
1109
  ---
412
1110
 
413
- ## MCP Server (AI Integration)
1111
+ ## Commands
1112
+
1113
+ ```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
1121
+ ```
414
1122
 
415
- An MCP server is available for AI assistants to create and manage plugins following project conventions.
1123
+ ---
416
1124
 
417
- ### Available Tools
1125
+ ## MCP Tools Available
418
1126
 
419
1127
  | Tool | Description |
420
1128
  |------|-------------|
1129
+ | `get_project_info` | Get project structure overview |
421
1130
  | `create_plugin` | Create a new plugin with correct structure |
422
- | `add_route` | Add a route to a router with proper typing |
423
- | `add_migration` | Create a numbered migration file |
424
- | `add_service_method` | Add a method to a plugin's service |
425
- | `generate_types` | Run type generation |
426
- | `list_plugins` | List all plugins with their methods |
427
- | `get_project_info` | Get project structure info |
428
-
429
- ### Configuration
430
-
431
- Add to your Claude Code MCP settings:
432
-
433
- ```json
434
- {
435
- "mcpServers": {
436
- "donkeylabs": {
437
- "command": "bun",
438
- "args": ["packages/mcp/src/server.ts"]
439
- }
440
- }
441
- }
442
- ```
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 |
443
1137
 
444
- The MCP server lives in the `packages/mcp/` directory of the monorepo.
445
-
446
- ### Example Usage
1138
+ ---
447
1139
 
448
- AI can call these tools to scaffold code correctly:
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 |
449
1158
 
450
- ```
451
- Tool: create_plugin
452
- Args: { "name": "notifications", "hasSchema": true, "dependencies": ["auth"] }
1159
+ ---
453
1160
 
454
- Result: Creates src/plugins/notifications/ with index.ts, schema.ts, migrations/
455
- ```
1161
+ ## Key Reminders
1162
+
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