@donkeylabs/server 0.4.3 → 0.4.4
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 +455 -0
- package/docs/testing.md +430 -0
- package/package.json +2 -1
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
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
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# @donkeylabs/server
|
|
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.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## AI Assistant Instructions
|
|
14
|
+
|
|
15
|
+
**IMPORTANT: Follow these guidelines when working with this codebase.**
|
|
16
|
+
|
|
17
|
+
### 1. Use MCP Tools First
|
|
18
|
+
|
|
19
|
+
When the `donkeylabs` MCP server is available, **always use MCP tools** instead of writing code manually:
|
|
20
|
+
|
|
21
|
+
| Task | Use MCP Tool |
|
|
22
|
+
|------|--------------|
|
|
23
|
+
| Create a plugin | `create_plugin` |
|
|
24
|
+
| Add a route | `add_route` |
|
|
25
|
+
| Add database migration | `add_migration` |
|
|
26
|
+
| Add service method | `add_service_method` |
|
|
27
|
+
| Generate types | `generate_types` |
|
|
28
|
+
|
|
29
|
+
MCP tools ensure correct file structure, naming conventions, and patterns.
|
|
30
|
+
|
|
31
|
+
### 2. Read Docs Before Implementing
|
|
32
|
+
|
|
33
|
+
Before implementing any feature, **read the relevant documentation**:
|
|
34
|
+
|
|
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) |
|
|
45
|
+
|
|
46
|
+
### 3. Key Patterns to Follow
|
|
47
|
+
|
|
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
|
|
53
|
+
|
|
54
|
+
### 4. Write Tests
|
|
55
|
+
|
|
56
|
+
**REQUIRED: Write tests for new functionality.** See [docs/testing.md](docs/testing.md)
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { createTestHarness } from "@donkeylabs/server/harness";
|
|
60
|
+
import { myPlugin } from "./plugins/myPlugin";
|
|
61
|
+
|
|
62
|
+
const { manager, db, core } = await createTestHarness(myPlugin);
|
|
63
|
+
const service = manager.getServices().myPlugin;
|
|
64
|
+
```
|
|
65
|
+
|
|
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`
|
|
69
|
+
|
|
70
|
+
### 5. Verify Before Committing
|
|
71
|
+
|
|
72
|
+
**REQUIRED: Always run these checks before finishing:**
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
# 1. Type check - catch type errors
|
|
76
|
+
bun --bun tsc --noEmit
|
|
77
|
+
|
|
78
|
+
# 2. Run tests - ensure nothing is broken
|
|
79
|
+
bun test
|
|
80
|
+
|
|
81
|
+
# 3. Generate types - if you added plugins/migrations
|
|
82
|
+
donkeylabs generate
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Do NOT skip these steps.** Type errors and failing tests must be fixed before completion.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Bun-First Development
|
|
90
|
+
|
|
91
|
+
Always use Bun instead of Node.js:
|
|
92
|
+
|
|
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
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Bun automatically loads `.env` - don't use dotenv.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
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)
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## User Project Structure
|
|
158
|
+
|
|
159
|
+
After running `donkeylabs init`:
|
|
160
|
+
|
|
161
|
+
```
|
|
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
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Quick Start
|
|
181
|
+
|
|
182
|
+
### 1. Create a Plugin
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
// src/plugins/myPlugin/index.ts
|
|
186
|
+
import { createPlugin } from "@donkeylabs/server";
|
|
187
|
+
|
|
188
|
+
export const myPlugin = createPlugin.define({
|
|
189
|
+
name: "myPlugin",
|
|
190
|
+
service: async (ctx) => ({
|
|
191
|
+
greet: (name: string) => `Hello, ${name}!`
|
|
192
|
+
})
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### 2. Create Routes
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
// src/index.ts
|
|
200
|
+
import { createRouter } from "@donkeylabs/server";
|
|
201
|
+
import { z } from "zod";
|
|
202
|
+
|
|
203
|
+
const router = createRouter("api")
|
|
204
|
+
.route("greet").typed({
|
|
205
|
+
input: z.object({ name: z.string() }),
|
|
206
|
+
handle: async (input, ctx) => {
|
|
207
|
+
return { message: ctx.plugins.myPlugin.greet(input.name) };
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### 3. Start Server
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
// src/index.ts
|
|
216
|
+
import { AppServer } from "@donkeylabs/server";
|
|
217
|
+
import { myPlugin } from "./plugins/myPlugin";
|
|
218
|
+
|
|
219
|
+
const server = new AppServer({
|
|
220
|
+
db: createDatabase(),
|
|
221
|
+
port: 3000,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
server.registerPlugin(myPlugin);
|
|
225
|
+
server.use(router);
|
|
226
|
+
await server.start();
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### 4. Make Requests
|
|
230
|
+
|
|
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!"}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## CLI Commands
|
|
241
|
+
|
|
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
|
+
```
|
|
248
|
+
|
|
249
|
+
### Interactive Mode
|
|
250
|
+
|
|
251
|
+
Running `donkeylabs` with no arguments launches an interactive menu:
|
|
252
|
+
|
|
253
|
+
**From project root:**
|
|
254
|
+
- Create New Plugin
|
|
255
|
+
- Initialize New Project
|
|
256
|
+
- Generate Types
|
|
257
|
+
- Generate Registry
|
|
258
|
+
- Generate Server Context
|
|
259
|
+
|
|
260
|
+
**From inside a plugin directory (`src/plugins/<name>/`):**
|
|
261
|
+
- Generate Schema Types
|
|
262
|
+
- Create Migration
|
|
263
|
+
- Back to Global Menu
|
|
264
|
+
|
|
265
|
+
### Development Commands
|
|
266
|
+
|
|
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
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## Server Context
|
|
278
|
+
|
|
279
|
+
Every route handler receives `ServerContext`:
|
|
280
|
+
|
|
281
|
+
```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
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
## Configuration File
|
|
300
|
+
|
|
301
|
+
```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
|
+
},
|
|
311
|
+
});
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## Testing
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
import { createTestHarness } from "@donkeylabs/server/harness";
|
|
320
|
+
import { myPlugin } from "./plugins/myPlugin";
|
|
321
|
+
|
|
322
|
+
const { manager, db, core } = await createTestHarness(myPlugin);
|
|
323
|
+
|
|
324
|
+
// Test with real in-memory SQLite + all core services
|
|
325
|
+
const service = manager.getServices().myPlugin;
|
|
326
|
+
expect(service.greet("Test")).toBe("Hello, Test!");
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
## Package Exports
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
// Main exports
|
|
335
|
+
import { createPlugin, AppServer, createRouter } from "@donkeylabs/server";
|
|
336
|
+
|
|
337
|
+
// Client base class
|
|
338
|
+
import { RpcClient } from "@donkeylabs/server/client";
|
|
339
|
+
|
|
340
|
+
// Test harness
|
|
341
|
+
import { createTestHarness } from "@donkeylabs/server/harness";
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
## Common Issues
|
|
347
|
+
|
|
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")
|
|
351
|
+
|
|
352
|
+
### Plugin types not recognized
|
|
353
|
+
1. Ensure `.@donkeylabs/server` is in your tsconfig's `include` array
|
|
354
|
+
2. Run `donkeylabs generate`
|
|
355
|
+
|
|
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
|
|
360
|
+
|
|
361
|
+
### Core services undefined
|
|
362
|
+
1. Check `ServerConfig` has required `db` property
|
|
363
|
+
2. Core services are auto-initialized in `AppServer` constructor
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
## Bun APIs
|
|
368
|
+
|
|
369
|
+
Use Bun's built-in APIs instead of npm packages:
|
|
370
|
+
|
|
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 |
|
|
380
|
+
|
|
381
|
+
See `node_modules/bun-types/docs/**.md` for full API documentation.
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
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 |
|
|
410
|
+
|
|
411
|
+
---
|
|
412
|
+
|
|
413
|
+
## MCP Server (AI Integration)
|
|
414
|
+
|
|
415
|
+
An MCP server is available for AI assistants to create and manage plugins following project conventions.
|
|
416
|
+
|
|
417
|
+
### Available Tools
|
|
418
|
+
|
|
419
|
+
| Tool | Description |
|
|
420
|
+
|------|-------------|
|
|
421
|
+
| `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
|
+
```
|
|
443
|
+
|
|
444
|
+
The MCP server lives in the `packages/mcp/` directory of the monorepo.
|
|
445
|
+
|
|
446
|
+
### Example Usage
|
|
447
|
+
|
|
448
|
+
AI can call these tools to scaffold code correctly:
|
|
449
|
+
|
|
450
|
+
```
|
|
451
|
+
Tool: create_plugin
|
|
452
|
+
Args: { "name": "notifications", "hasSchema": true, "dependencies": ["auth"] }
|
|
453
|
+
|
|
454
|
+
Result: Creates src/plugins/notifications/ with index.ts, schema.ts, migrations/
|
|
455
|
+
```
|
package/docs/testing.md
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
This guide covers testing plugins and routes using the built-in test harness.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Test Harness](#test-harness)
|
|
8
|
+
- [Unit Testing Plugins](#unit-testing-plugins)
|
|
9
|
+
- [Integration Testing](#integration-testing)
|
|
10
|
+
- [Testing Routes](#testing-routes)
|
|
11
|
+
- [Mocking Core Services](#mocking-core-services)
|
|
12
|
+
- [Test Organization](#test-organization)
|
|
13
|
+
- [Running Tests](#running-tests)
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Test Harness
|
|
18
|
+
|
|
19
|
+
The test harness creates a fully functional in-memory testing environment with real SQLite, migrations, and all core services.
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { createTestHarness } from "@donkeylabs/server/harness";
|
|
23
|
+
import { myPlugin } from "./plugins/myPlugin";
|
|
24
|
+
|
|
25
|
+
const { manager, db, core } = await createTestHarness(myPlugin);
|
|
26
|
+
|
|
27
|
+
// Access plugin services
|
|
28
|
+
const service = manager.getServices().myPlugin;
|
|
29
|
+
|
|
30
|
+
// Access database directly
|
|
31
|
+
const rows = await db.selectFrom("my_table").selectAll().execute();
|
|
32
|
+
|
|
33
|
+
// Access core services
|
|
34
|
+
core.logger.info("Test log");
|
|
35
|
+
core.cache.set("key", "value");
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### With Dependencies
|
|
39
|
+
|
|
40
|
+
If your plugin depends on other plugins, pass them as the second argument:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { createTestHarness } from "@donkeylabs/server/harness";
|
|
44
|
+
import { ordersPlugin } from "./plugins/orders";
|
|
45
|
+
import { usersPlugin } from "./plugins/users";
|
|
46
|
+
|
|
47
|
+
// ordersPlugin depends on usersPlugin
|
|
48
|
+
const { manager } = await createTestHarness(ordersPlugin, [usersPlugin]);
|
|
49
|
+
|
|
50
|
+
const orders = manager.getServices().orders;
|
|
51
|
+
const users = manager.getServices().users;
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Unit Testing Plugins
|
|
57
|
+
|
|
58
|
+
Unit tests verify individual plugin methods in isolation.
|
|
59
|
+
|
|
60
|
+
### Basic Plugin Test
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// plugins/calculator/calculator.test.ts
|
|
64
|
+
import { describe, test, expect } from "bun:test";
|
|
65
|
+
import { createTestHarness } from "@donkeylabs/server/harness";
|
|
66
|
+
import { calculatorPlugin } from "./index";
|
|
67
|
+
|
|
68
|
+
describe("calculatorPlugin", () => {
|
|
69
|
+
test("add() returns correct sum", async () => {
|
|
70
|
+
const { manager } = await createTestHarness(calculatorPlugin);
|
|
71
|
+
const calc = manager.getServices().calculator;
|
|
72
|
+
|
|
73
|
+
expect(calc.add(2, 3)).toBe(5);
|
|
74
|
+
expect(calc.add(-1, 1)).toBe(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("divide() throws on zero", async () => {
|
|
78
|
+
const { manager } = await createTestHarness(calculatorPlugin);
|
|
79
|
+
const calc = manager.getServices().calculator;
|
|
80
|
+
|
|
81
|
+
expect(() => calc.divide(10, 0)).toThrow("Cannot divide by zero");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Testing Database Operations
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
// plugins/users/users.test.ts
|
|
90
|
+
import { describe, test, expect, beforeEach } from "bun:test";
|
|
91
|
+
import { createTestHarness } from "@donkeylabs/server/harness";
|
|
92
|
+
import { usersPlugin } from "./index";
|
|
93
|
+
|
|
94
|
+
describe("usersPlugin", () => {
|
|
95
|
+
let users: ReturnType<typeof manager.getServices>["users"];
|
|
96
|
+
let db: Awaited<ReturnType<typeof createTestHarness>>["db"];
|
|
97
|
+
|
|
98
|
+
beforeEach(async () => {
|
|
99
|
+
const harness = await createTestHarness(usersPlugin);
|
|
100
|
+
users = harness.manager.getServices().users;
|
|
101
|
+
db = harness.db;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("create() inserts user into database", async () => {
|
|
105
|
+
const user = await users.create({
|
|
106
|
+
email: "test@example.com",
|
|
107
|
+
name: "Test User",
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(user.id).toBeDefined();
|
|
111
|
+
expect(user.email).toBe("test@example.com");
|
|
112
|
+
|
|
113
|
+
// Verify in database
|
|
114
|
+
const dbUser = await db
|
|
115
|
+
.selectFrom("users")
|
|
116
|
+
.where("id", "=", user.id)
|
|
117
|
+
.selectAll()
|
|
118
|
+
.executeTakeFirst();
|
|
119
|
+
|
|
120
|
+
expect(dbUser).toBeDefined();
|
|
121
|
+
expect(dbUser?.email).toBe("test@example.com");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("findByEmail() returns null for non-existent user", async () => {
|
|
125
|
+
const user = await users.findByEmail("notfound@example.com");
|
|
126
|
+
expect(user).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("findByEmail() returns user when exists", async () => {
|
|
130
|
+
await users.create({ email: "exists@example.com", name: "Exists" });
|
|
131
|
+
|
|
132
|
+
const user = await users.findByEmail("exists@example.com");
|
|
133
|
+
expect(user).not.toBeNull();
|
|
134
|
+
expect(user?.name).toBe("Exists");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Integration Testing
|
|
142
|
+
|
|
143
|
+
Integration tests verify multiple plugins working together.
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
// tests/checkout.integ.test.ts
|
|
147
|
+
import { describe, test, expect, beforeEach } from "bun:test";
|
|
148
|
+
import { createTestHarness } from "@donkeylabs/server/harness";
|
|
149
|
+
import { ordersPlugin } from "../plugins/orders";
|
|
150
|
+
import { usersPlugin } from "../plugins/users";
|
|
151
|
+
import { inventoryPlugin } from "../plugins/inventory";
|
|
152
|
+
|
|
153
|
+
describe("Checkout Integration", () => {
|
|
154
|
+
let services: {
|
|
155
|
+
orders: ReturnType<typeof manager.getServices>["orders"];
|
|
156
|
+
users: ReturnType<typeof manager.getServices>["users"];
|
|
157
|
+
inventory: ReturnType<typeof manager.getServices>["inventory"];
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
beforeEach(async () => {
|
|
161
|
+
const { manager } = await createTestHarness(ordersPlugin, [
|
|
162
|
+
usersPlugin,
|
|
163
|
+
inventoryPlugin,
|
|
164
|
+
]);
|
|
165
|
+
services = manager.getServices() as typeof services;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("checkout reduces inventory and creates order", async () => {
|
|
169
|
+
// Setup: Create user and add inventory
|
|
170
|
+
const user = await services.users.create({
|
|
171
|
+
email: "buyer@example.com",
|
|
172
|
+
name: "Buyer",
|
|
173
|
+
});
|
|
174
|
+
await services.inventory.add("SKU-001", 10);
|
|
175
|
+
|
|
176
|
+
// Action: Checkout
|
|
177
|
+
const order = await services.orders.checkout({
|
|
178
|
+
userId: user.id,
|
|
179
|
+
items: [{ sku: "SKU-001", quantity: 2 }],
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Assert: Order created
|
|
183
|
+
expect(order.status).toBe("completed");
|
|
184
|
+
expect(order.items).toHaveLength(1);
|
|
185
|
+
|
|
186
|
+
// Assert: Inventory reduced
|
|
187
|
+
const stock = await services.inventory.getStock("SKU-001");
|
|
188
|
+
expect(stock).toBe(8);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("checkout fails when insufficient inventory", async () => {
|
|
192
|
+
const user = await services.users.create({
|
|
193
|
+
email: "buyer@example.com",
|
|
194
|
+
name: "Buyer",
|
|
195
|
+
});
|
|
196
|
+
await services.inventory.add("SKU-002", 1);
|
|
197
|
+
|
|
198
|
+
await expect(
|
|
199
|
+
services.orders.checkout({
|
|
200
|
+
userId: user.id,
|
|
201
|
+
items: [{ sku: "SKU-002", quantity: 5 }],
|
|
202
|
+
})
|
|
203
|
+
).rejects.toThrow("Insufficient inventory");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Testing Routes
|
|
211
|
+
|
|
212
|
+
For route testing, use Bun's built-in fetch or create a test server.
|
|
213
|
+
|
|
214
|
+
### Direct Handler Testing
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
// routes/users/users.test.ts
|
|
218
|
+
import { describe, test, expect, beforeEach } from "bun:test";
|
|
219
|
+
import { createTestHarness } from "@donkeylabs/server/harness";
|
|
220
|
+
import { usersPlugin } from "../../plugins/users";
|
|
221
|
+
import { CreateUserHandler } from "./handlers/create-user";
|
|
222
|
+
|
|
223
|
+
describe("CreateUserHandler", () => {
|
|
224
|
+
let ctx: Awaited<ReturnType<typeof createTestHarness>>["core"] & {
|
|
225
|
+
plugins: ReturnType<typeof manager.getServices>;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
beforeEach(async () => {
|
|
229
|
+
const { manager, core } = await createTestHarness(usersPlugin);
|
|
230
|
+
ctx = {
|
|
231
|
+
...core,
|
|
232
|
+
plugins: manager.getServices(),
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("creates user with valid input", async () => {
|
|
237
|
+
const handler = new CreateUserHandler(ctx as any);
|
|
238
|
+
const result = await handler.handle({
|
|
239
|
+
email: "new@example.com",
|
|
240
|
+
name: "New User",
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(result.id).toBeDefined();
|
|
244
|
+
expect(result.email).toBe("new@example.com");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Full HTTP Testing
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
// tests/api.test.ts
|
|
253
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
254
|
+
import { AppServer } from "@donkeylabs/server";
|
|
255
|
+
import { usersPlugin } from "../plugins/users";
|
|
256
|
+
import { usersRouter } from "../routes/users";
|
|
257
|
+
|
|
258
|
+
describe("Users API", () => {
|
|
259
|
+
let server: AppServer;
|
|
260
|
+
let baseUrl: string;
|
|
261
|
+
|
|
262
|
+
beforeAll(async () => {
|
|
263
|
+
server = new AppServer({
|
|
264
|
+
db: createTestDb(),
|
|
265
|
+
port: 0, // Random available port
|
|
266
|
+
});
|
|
267
|
+
server.registerPlugin(usersPlugin);
|
|
268
|
+
server.use(usersRouter);
|
|
269
|
+
await server.start();
|
|
270
|
+
baseUrl = `http://localhost:${server.port}`;
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
afterAll(async () => {
|
|
274
|
+
await server.stop();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("POST /users.create creates a user", async () => {
|
|
278
|
+
const response = await fetch(`${baseUrl}/users.create`, {
|
|
279
|
+
method: "POST",
|
|
280
|
+
headers: { "Content-Type": "application/json" },
|
|
281
|
+
body: JSON.stringify({
|
|
282
|
+
email: "api@example.com",
|
|
283
|
+
name: "API User",
|
|
284
|
+
}),
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
expect(response.status).toBe(200);
|
|
288
|
+
const data = await response.json();
|
|
289
|
+
expect(data.id).toBeDefined();
|
|
290
|
+
expect(data.email).toBe("api@example.com");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("POST /users.create returns 400 for invalid email", async () => {
|
|
294
|
+
const response = await fetch(`${baseUrl}/users.create`, {
|
|
295
|
+
method: "POST",
|
|
296
|
+
headers: { "Content-Type": "application/json" },
|
|
297
|
+
body: JSON.stringify({
|
|
298
|
+
email: "not-an-email",
|
|
299
|
+
name: "Bad User",
|
|
300
|
+
}),
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect(response.status).toBe(400);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## Mocking Core Services
|
|
311
|
+
|
|
312
|
+
The test harness provides real implementations, but you can mock specific services:
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
import { describe, test, expect, mock } from "bun:test";
|
|
316
|
+
import { createTestHarness } from "@donkeylabs/server/harness";
|
|
317
|
+
import { notificationsPlugin } from "./index";
|
|
318
|
+
|
|
319
|
+
describe("notificationsPlugin with mocked email", () => {
|
|
320
|
+
test("sendEmail() is called with correct args", async () => {
|
|
321
|
+
const { manager, core } = await createTestHarness(notificationsPlugin);
|
|
322
|
+
|
|
323
|
+
// Mock the email sending function
|
|
324
|
+
const sendEmailMock = mock(() => Promise.resolve());
|
|
325
|
+
const notifications = manager.getServices().notifications;
|
|
326
|
+
notifications.sendEmail = sendEmailMock;
|
|
327
|
+
|
|
328
|
+
await notifications.notifyUser("user-123", "Hello!");
|
|
329
|
+
|
|
330
|
+
expect(sendEmailMock).toHaveBeenCalledTimes(1);
|
|
331
|
+
expect(sendEmailMock).toHaveBeenCalledWith(
|
|
332
|
+
expect.objectContaining({
|
|
333
|
+
to: expect.any(String),
|
|
334
|
+
subject: expect.stringContaining("Hello"),
|
|
335
|
+
})
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## Test Organization
|
|
344
|
+
|
|
345
|
+
### Recommended Structure
|
|
346
|
+
|
|
347
|
+
```
|
|
348
|
+
src/
|
|
349
|
+
├── plugins/
|
|
350
|
+
│ └── users/
|
|
351
|
+
│ ├── index.ts
|
|
352
|
+
│ ├── schema.ts
|
|
353
|
+
│ ├── migrations/
|
|
354
|
+
│ └── tests/
|
|
355
|
+
│ ├── unit.test.ts # Unit tests for service methods
|
|
356
|
+
│ └── integ.test.ts # Integration tests with other plugins
|
|
357
|
+
├── routes/
|
|
358
|
+
│ └── users/
|
|
359
|
+
│ ├── index.ts
|
|
360
|
+
│ ├── handlers/
|
|
361
|
+
│ └── tests/
|
|
362
|
+
│ └── api.test.ts # Route/API tests
|
|
363
|
+
└── tests/
|
|
364
|
+
└── e2e/ # End-to-end tests
|
|
365
|
+
└── checkout.test.ts
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### Naming Conventions
|
|
369
|
+
|
|
370
|
+
- `*.test.ts` - Unit tests (run with `bun test`)
|
|
371
|
+
- `*.integ.test.ts` - Integration tests
|
|
372
|
+
- `*.e2e.test.ts` - End-to-end tests
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
376
|
+
## Running Tests
|
|
377
|
+
|
|
378
|
+
```sh
|
|
379
|
+
# Run all tests
|
|
380
|
+
bun test
|
|
381
|
+
|
|
382
|
+
# Run tests for a specific plugin
|
|
383
|
+
bun test plugins/users
|
|
384
|
+
|
|
385
|
+
# Run tests matching a pattern
|
|
386
|
+
bun test --grep "create"
|
|
387
|
+
|
|
388
|
+
# Run tests in watch mode
|
|
389
|
+
bun test --watch
|
|
390
|
+
|
|
391
|
+
# Run with coverage
|
|
392
|
+
bun test --coverage
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### Type Checking
|
|
396
|
+
|
|
397
|
+
Always run type checking before committing:
|
|
398
|
+
|
|
399
|
+
```sh
|
|
400
|
+
bun --bun tsc --noEmit
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### CI Pipeline Example
|
|
404
|
+
|
|
405
|
+
```yaml
|
|
406
|
+
# .github/workflows/test.yml
|
|
407
|
+
name: Test
|
|
408
|
+
on: [push, pull_request]
|
|
409
|
+
jobs:
|
|
410
|
+
test:
|
|
411
|
+
runs-on: ubuntu-latest
|
|
412
|
+
steps:
|
|
413
|
+
- uses: actions/checkout@v4
|
|
414
|
+
- uses: oven-sh/setup-bun@v1
|
|
415
|
+
- run: bun install
|
|
416
|
+
- run: bun --bun tsc --noEmit
|
|
417
|
+
- run: bun test
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
422
|
+
## Best Practices
|
|
423
|
+
|
|
424
|
+
1. **Use fresh harness per test** - Create a new harness in `beforeEach` to ensure test isolation
|
|
425
|
+
2. **Test the public API** - Focus on testing service methods, not internal implementation
|
|
426
|
+
3. **Use realistic data** - Create test data that resembles production data
|
|
427
|
+
4. **Test edge cases** - Empty inputs, null values, boundary conditions
|
|
428
|
+
5. **Test error cases** - Verify proper error throwing and handling
|
|
429
|
+
6. **Keep tests fast** - In-memory SQLite is fast; avoid unnecessary delays
|
|
430
|
+
7. **Run type checks** - Always run `tsc --noEmit` before committing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@donkeylabs/server",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Type-safe plugin system for building RPC-style APIs with Bun",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"files": [
|
|
31
31
|
"src",
|
|
32
32
|
"docs",
|
|
33
|
+
"CLAUDE.md",
|
|
33
34
|
"context.d.ts",
|
|
34
35
|
"registry.d.ts",
|
|
35
36
|
"LICENSE",
|