@donkeylabs/server 0.4.2 → 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/docs/plugins.md CHANGED
@@ -4,6 +4,7 @@ Plugins are the core building blocks of this framework. Each plugin encapsulates
4
4
 
5
5
  ## Table of Contents
6
6
 
7
+ - [When to Create a Plugin vs Route](#when-to-create-a-plugin-vs-route)
7
8
  - [Creating a Plugin](#creating-a-plugin)
8
9
  - [Plugin with Database Schema](#plugin-with-database-schema)
9
10
  - [Plugin with Configuration](#plugin-with-configuration)
@@ -16,6 +17,126 @@ Plugins are the core building blocks of this framework. Each plugin encapsulates
16
17
 
17
18
  ---
18
19
 
20
+ ## When to Create a Plugin vs Route
21
+
22
+ Understanding when to create a plugin versus a route is fundamental to building maintainable applications.
23
+
24
+ ### The Core Principle
25
+
26
+ **Plugins = Reusable Business Logic** | **Routes = App-Specific API Endpoints**
27
+
28
+ Think of plugins as your application's "services layer" and routes as your "API layer".
29
+
30
+ ### Create a Plugin When:
31
+
32
+ 1. **The logic could be reused** across multiple routes or applications
33
+ - Example: User authentication, email sending, payment processing
34
+
35
+ 2. **You need database tables** for a domain concept
36
+ - Example: A "users" plugin that owns the users table and provides CRUD methods
37
+
38
+ 3. **The functionality is self-contained** with its own data and operations
39
+ - Example: A "notifications" plugin with its own tables, delivery logic, and scheduling
40
+
41
+ 4. **You want to share state or connections** (database, external APIs)
42
+ - Example: A "stripe" plugin that manages the Stripe SDK connection
43
+
44
+ 5. **You're building cross-cutting concerns** like auth middleware, rate limiting, or logging
45
+
46
+ ### Create a Route When:
47
+
48
+ 1. **Exposing plugin functionality** to the outside world via HTTP
49
+ - Example: A `users.create` route that calls `ctx.plugins.users.create()`
50
+
51
+ 2. **Combining multiple plugins** for a specific use case
52
+ - Example: A checkout route that uses cart, payment, and inventory plugins
53
+
54
+ 3. **App-specific endpoints** that don't need to be reused
55
+ - Example: A dashboard summary endpoint specific to your app
56
+
57
+ 4. **Simple operations** that don't warrant a full plugin
58
+ - Example: A health check endpoint
59
+
60
+ ### Decision Flowchart
61
+
62
+ ```
63
+ Is this logic reusable across routes or apps?
64
+ ├── Yes → Create a Plugin
65
+ └── No
66
+ ├── Does it need its own database tables?
67
+ │ └── Yes → Create a Plugin (with hasSchema: true)
68
+ └── Is it just exposing existing functionality via HTTP?
69
+ └── Yes → Create a Route that uses existing plugins
70
+ ```
71
+
72
+ ### Example: Building a Task Management System
73
+
74
+ **Step 1: Identify reusable domains → Create Plugins**
75
+ - `tasks` plugin - owns tasks table, CRUD methods
76
+ - `users` plugin - owns users table, auth methods
77
+ - `notifications` plugin - handles email/push notifications
78
+
79
+ **Step 2: Create app-specific routes that use plugins**
80
+ ```ts
81
+ // routes/tasks/index.ts
82
+ router.route("create").typed({
83
+ input: CreateTaskInput,
84
+ handle: async (input, ctx) => {
85
+ // Use tasks plugin for business logic
86
+ const task = await ctx.plugins.tasks.create(input);
87
+
88
+ // Use notifications plugin for side effects
89
+ await ctx.plugins.notifications.notify(
90
+ task.assigneeId,
91
+ `New task: ${task.title}`
92
+ );
93
+
94
+ return task;
95
+ },
96
+ });
97
+ ```
98
+
99
+ ### Anti-Patterns to Avoid
100
+
101
+ ❌ **Don't put business logic in routes** - Routes should be thin; delegate to plugins
102
+
103
+ ```ts
104
+ // BAD: Business logic in route
105
+ router.route("create").typed({
106
+ handle: async (input, ctx) => {
107
+ // 50 lines of validation, database calls, notifications...
108
+ },
109
+ });
110
+
111
+ // GOOD: Thin route, logic in plugin
112
+ router.route("create").typed({
113
+ handle: async (input, ctx) => {
114
+ return ctx.plugins.tasks.create(input);
115
+ },
116
+ });
117
+ ```
118
+
119
+ ❌ **Don't create a plugin for every route** - Only for reusable logic
120
+
121
+ ```ts
122
+ // BAD: Plugin just wrapping a single database call
123
+ export const dashboardPlugin = createPlugin.define({
124
+ name: "dashboard",
125
+ service: async (ctx) => ({
126
+ getSummary: () => ctx.db.selectFrom("stats").execute(),
127
+ }),
128
+ });
129
+
130
+ // GOOD: Just make it a route
131
+ router.route("dashboard.summary").typed({
132
+ handle: async (_, ctx) => {
133
+ return ctx.db.selectFrom("stats").execute();
134
+ },
135
+ });
136
+ ```
137
+
138
+ ---
139
+
19
140
  ## Creating a Plugin
20
141
 
21
142
  The simplest plugin exports a service that becomes available to all route handlers:
@@ -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.2",
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",