@donkeylabs/server 2.0.37 → 2.1.0

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.
@@ -0,0 +1,424 @@
1
+ # Code Organization Guide
2
+
3
+ Rules for structuring Donkeylabs plugins and routes as they grow. Follow these rules exactly — they prevent the most common structural problems.
4
+
5
+ ---
6
+
7
+ ## 1. File Size Rules
8
+
9
+ | Threshold | Action |
10
+ |-----------|--------|
11
+ | Any file > 200 lines | Split it |
12
+ | Plugin `index.ts` > 300 lines | Extract service, types, helpers |
13
+ | Route handler > 50 lines | Move business logic to plugin service |
14
+ | Single function > 40 lines | Break into smaller functions |
15
+
16
+ **The 300-line rule for `index.ts`**: A plugin's `index.ts` should contain only the plugin definition and wiring. If it's over 300 lines, you have business logic that belongs in a separate `service.ts`.
17
+
18
+ ---
19
+
20
+ ## 2. Plugin File Structure
21
+
22
+ ### Small plugin (< 200 lines total) — single file is fine
23
+
24
+ ```
25
+ plugins/notifications/
26
+ ├── index.ts # Everything here
27
+ ├── schema.ts # If using database
28
+ └── migrations/
29
+ ```
30
+
31
+ ### Medium plugin (200–500 lines) — extract the service
32
+
33
+ ```
34
+ plugins/orders/
35
+ ├── index.ts # Plugin definition + wiring only
36
+ ├── service.ts # Service class with business logic
37
+ ├── schema.ts
38
+ └── migrations/
39
+ ```
40
+
41
+ ### Large plugin (500+ lines) — full split
42
+
43
+ ```
44
+ plugins/auth/
45
+ ├── index.ts # Plugin definition + wiring only (< 100 lines)
46
+ ├── types.ts # Interfaces, type aliases, enums
47
+ ├── service.ts # Service class with business logic
48
+ ├── helpers.ts # Pure utility functions
49
+ ├── constants.ts # Configuration constants, magic strings
50
+ ├── schema.ts
51
+ └── migrations/
52
+ ```
53
+
54
+ ### What goes where
55
+
56
+ | File | Contains | Does NOT contain |
57
+ |------|----------|-----------------|
58
+ | `index.ts` | `createPlugin.define()`, imports, wiring | Business logic, type definitions, helpers |
59
+ | `service.ts` | Service class, all business methods | Plugin definition, Zod schemas |
60
+ | `types.ts` | Interfaces, type aliases, enums | Implementation code |
61
+ | `helpers.ts` | Pure functions (no `ctx` dependency) | Stateful logic, database calls |
62
+ | `constants.ts` | String literals, config defaults, enums | Functions, classes |
63
+
64
+ ---
65
+
66
+ ## 3. Service Class Pattern
67
+
68
+ For any plugin with more than 2-3 trivial methods, use a service class.
69
+
70
+ ### BAD: Inline object literal with all logic in `index.ts`
71
+
72
+ ```ts
73
+ // plugins/orders/index.ts — 800 lines, untestable, unreadable
74
+ export const ordersPlugin = createPlugin.withSchema<DB>().define({
75
+ name: "orders",
76
+ service: async (ctx) => ({
77
+ async create(data: OrderInput) {
78
+ // 40 lines of validation...
79
+ // 30 lines of database calls...
80
+ // 20 lines of event emission...
81
+ },
82
+ async fulfill(orderId: string) {
83
+ // 60 more lines...
84
+ },
85
+ async refund(orderId: string, reason: string) {
86
+ // 50 more lines...
87
+ },
88
+ // ... 10 more methods
89
+ }),
90
+ });
91
+ ```
92
+
93
+ ### GOOD: Service class in separate file
94
+
95
+ ```ts
96
+ // plugins/orders/service.ts
97
+ import type { PluginContext } from "../../core";
98
+ import type { DB } from "./schema";
99
+
100
+ export class OrdersService {
101
+ constructor(private ctx: PluginContext<DB>) {}
102
+
103
+ async create(data: OrderInput) {
104
+ const validated = this.validateOrder(data);
105
+ const order = await this.ctx.db
106
+ .insertInto("orders")
107
+ .values(validated)
108
+ .returningAll()
109
+ .executeTakeFirstOrThrow();
110
+
111
+ await this.ctx.core.events.emit("order.created", { orderId: order.id });
112
+ return order;
113
+ }
114
+
115
+ async fulfill(orderId: string) {
116
+ // Clear, focused method
117
+ }
118
+
119
+ async refund(orderId: string, reason: string) {
120
+ // Clear, focused method
121
+ }
122
+
123
+ private validateOrder(data: OrderInput) {
124
+ // Private helper — testable via public methods
125
+ }
126
+ }
127
+ ```
128
+
129
+ ```ts
130
+ // plugins/orders/index.ts — thin wiring, ~30 lines
131
+ import { createPlugin } from "../../core";
132
+ import type { DB } from "./schema";
133
+ import { OrdersService } from "./service";
134
+
135
+ export const ordersPlugin = createPlugin.withSchema<DB>().define({
136
+ name: "orders",
137
+ service: async (ctx) => {
138
+ const svc = new OrdersService(ctx);
139
+ return {
140
+ create: svc.create.bind(svc),
141
+ fulfill: svc.fulfill.bind(svc),
142
+ refund: svc.refund.bind(svc),
143
+ };
144
+ },
145
+ });
146
+ ```
147
+
148
+ ### Why classes?
149
+
150
+ - **Testable**: Instantiate with a mock `ctx`, test methods in isolation
151
+ - **Readable**: Each method is focused, private helpers stay private
152
+ - **Navigable**: IDE jump-to-definition works, not lost in a 500-line object literal
153
+ - **Maintainable**: Adding a method doesn't balloon a single file
154
+
155
+ ---
156
+
157
+ ## 4. Generated Types — NEVER Use `as any`
158
+
159
+ After running `donkeylabs generate`, the framework produces fully-typed declarations. Use them.
160
+
161
+ ### Rule: zero `as any` casts for framework types
162
+
163
+ If you find yourself writing `as any`, you either:
164
+ 1. Haven't run `donkeylabs generate` yet, or
165
+ 2. Are importing the wrong type
166
+
167
+ ### Available generated types
168
+
169
+ | Type | Source | Usage |
170
+ |------|--------|-------|
171
+ | `AppContext` | Inferred from your server instance | Route handlers, middleware |
172
+ | `PluginRegistry` | Generated from registered plugins | `ctx.plugins.<name>` is fully typed |
173
+ | `ServiceRegistry` | Generated from `defineService()` calls | `ctx.services.<name>` is fully typed |
174
+ | Route types | Generated from router schemas | Import input/output types |
175
+
176
+ ### BAD: Casting to `any`
177
+
178
+ ```ts
179
+ // Route handler with no type safety
180
+ router.route("create").typed({
181
+ input: z.object({ email: z.string() }),
182
+ handle: async (input: any, ctx: any) => {
183
+ const users = (ctx as any).plugins.users;
184
+ const result = await users.create(input);
185
+ return result as any;
186
+ },
187
+ });
188
+ ```
189
+
190
+ ### GOOD: Using inferred types
191
+
192
+ ```ts
193
+ // Types flow automatically — no casts needed
194
+ router.route("create").typed({
195
+ input: z.object({ email: z.string() }),
196
+ handle: async (input, ctx) => {
197
+ // ctx.plugins.users is fully typed after `donkeylabs generate`
198
+ return ctx.plugins.users.create(input);
199
+ },
200
+ });
201
+ ```
202
+
203
+ ### BAD: Typing `ctx` manually
204
+
205
+ ```ts
206
+ // Don't hand-write context types
207
+ service: async (ctx: { db: any; core: any; deps: any }) => {
208
+ ```
209
+
210
+ ### GOOD: Let the framework infer
211
+
212
+ ```ts
213
+ // ctx is fully typed by createPlugin's generics
214
+ service: async (ctx) => {
215
+ ctx.db; // Typed as Kysely<DB> (from withSchema<DB>())
216
+ ctx.core; // Typed as CoreServices
217
+ ctx.deps; // Typed based on dependencies array
218
+ ctx.config; // Typed as your config interface (from withConfig<T>())
219
+ }
220
+ ```
221
+
222
+ ### Getting your app's context type
223
+
224
+ ```ts
225
+ // In your server setup file
226
+ import { AppServer } from "@donkeylabs/server";
227
+
228
+ const server = new AppServer({ ... });
229
+
230
+ // Export the context type for use across your app
231
+ export type AppContext = typeof server extends AppServer<infer C> ? C : never;
232
+ ```
233
+
234
+ ---
235
+
236
+ ## 5. Route Type Imports — Don't Duplicate Zod Schemas
237
+
238
+ When route schemas are defined in a router, don't recreate them elsewhere. Import the inferred types instead.
239
+
240
+ ### BAD: Duplicating schemas
241
+
242
+ ```ts
243
+ // routes/users.ts
244
+ export const usersRouter = createRouter("users")
245
+ .route("create").typed({
246
+ input: z.object({ email: z.string(), name: z.string() }),
247
+ handle: async (input, ctx) => ctx.plugins.users.create(input),
248
+ });
249
+
250
+ // plugins/users/index.ts — duplicated schema!
251
+ const CreateUserInput = z.object({ email: z.string(), name: z.string() });
252
+ ```
253
+
254
+ Now you have two sources of truth. When one changes, the other doesn't.
255
+
256
+ ### GOOD: Single source of truth
257
+
258
+ Define schemas in one place (the router or a shared schemas file), then import the inferred types:
259
+
260
+ ```ts
261
+ // routes/users/users.schemas.ts — single source of truth
262
+ import { z } from "zod";
263
+
264
+ export const createUserInput = z.object({
265
+ email: z.string().email(),
266
+ name: z.string().min(1),
267
+ });
268
+
269
+ export type CreateUserInput = z.infer<typeof createUserInput>;
270
+ ```
271
+
272
+ ```ts
273
+ // routes/users/index.ts
274
+ import { createUserInput } from "./users.schemas";
275
+
276
+ export const usersRouter = createRouter("users")
277
+ .route("create").typed({
278
+ input: createUserInput,
279
+ handle: async (input, ctx) => ctx.plugins.users.create(input),
280
+ });
281
+ ```
282
+
283
+ ```ts
284
+ // plugins/users/service.ts — imports the TYPE, not the schema
285
+ import type { CreateUserInput } from "../../routes/users/users.schemas";
286
+
287
+ export class UsersService {
288
+ async create(data: CreateUserInput) {
289
+ // ...
290
+ }
291
+ }
292
+ ```
293
+
294
+ ### Where to define schemas
295
+
296
+ | Scenario | Location |
297
+ |----------|----------|
298
+ | Used by one route only | Inline in the router file |
299
+ | Used by route + plugin service | `routes/<name>/<name>.schemas.ts` |
300
+ | Used across multiple features | Shared `schemas/` directory |
301
+
302
+ ---
303
+
304
+ ## 6. Anti-Pattern Gallery
305
+
306
+ ### Anti-pattern: Monolithic plugin file
307
+
308
+ **Symptom**: A single `index.ts` with 1000+ lines containing types, helpers, constants, and all service methods.
309
+
310
+ ```ts
311
+ // BAD: plugins/billing/index.ts — 1200 lines
312
+ interface Invoice { ... }
313
+ interface PaymentMethod { ... }
314
+ interface Subscription { ... }
315
+ const TAX_RATES = { ... };
316
+ const CURRENCY_FORMATS = { ... };
317
+ function calculateTax(...) { ... }
318
+ function formatCurrency(...) { ... }
319
+ export const billingPlugin = createPlugin.define({
320
+ name: "billing",
321
+ service: async (ctx) => ({
322
+ // 800 lines of methods...
323
+ }),
324
+ });
325
+ ```
326
+
327
+ **Fix**: Split into `types.ts`, `constants.ts`, `helpers.ts`, `service.ts`, and a thin `index.ts`.
328
+
329
+ ---
330
+
331
+ ### Anti-pattern: `as any` to silence type errors
332
+
333
+ **Symptom**: TypeScript errors "fixed" by casting instead of using correct types.
334
+
335
+ ```ts
336
+ // BAD
337
+ const user = await (ctx as any).plugins.users.getById(id);
338
+ return { data: result } as any;
339
+ ```
340
+
341
+ **Fix**: Run `donkeylabs generate` to update types. If types are still wrong, check that your plugin is registered in the server and the service return type is correct.
342
+
343
+ ---
344
+
345
+ ### Anti-pattern: Business logic in route handlers
346
+
347
+ **Symptom**: Route handlers with 30+ lines of logic instead of delegating to plugin services.
348
+
349
+ ```ts
350
+ // BAD
351
+ router.route("create").typed({
352
+ input: createOrderInput,
353
+ handle: async (input, ctx) => {
354
+ // 50 lines of validation, DB calls, event emission...
355
+ },
356
+ });
357
+ ```
358
+
359
+ **Fix**: Move logic to plugin service. Route handler should be a one-liner:
360
+
361
+ ```ts
362
+ // GOOD
363
+ handle: async (input, ctx) => ctx.plugins.orders.create(input),
364
+ ```
365
+
366
+ ---
367
+
368
+ ### Anti-pattern: Duplicated Zod schemas
369
+
370
+ **Symptom**: The same shape defined in both the router and the plugin.
371
+
372
+ **Fix**: Define once in a `.schemas.ts` file, import the schema in the router and the inferred type in the service. See [Section 5](#5-route-type-imports--dont-duplicate-zod-schemas).
373
+
374
+ ---
375
+
376
+ ### Anti-pattern: No dependency injection
377
+
378
+ **Symptom**: Service functions that import and call other services directly instead of receiving them through `ctx`.
379
+
380
+ ```ts
381
+ // BAD: Hard-coded dependency
382
+ import { db } from "../../database";
383
+
384
+ export function createOrder(data: OrderInput) {
385
+ return db.insertInto("orders").values(data).execute();
386
+ }
387
+ ```
388
+
389
+ **Fix**: Use the plugin context:
390
+
391
+ ```ts
392
+ // GOOD: Injected via ctx
393
+ export class OrdersService {
394
+ constructor(private ctx: PluginContext<DB>) {}
395
+
396
+ async createOrder(data: OrderInput) {
397
+ return this.ctx.db.insertInto("orders").values(data).execute();
398
+ }
399
+ }
400
+ ```
401
+
402
+ ---
403
+
404
+ ## Quick Reference
405
+
406
+ ```
407
+ Is your plugin file > 200 lines?
408
+ ├─ No → Single file is fine
409
+ └─ Yes → Is it > 500 lines?
410
+ ├─ No → Extract service.ts
411
+ └─ Yes → Full split: types.ts, service.ts, helpers.ts, constants.ts
412
+
413
+ Is your route handler > 3 lines?
414
+ ├─ No → Fine as-is
415
+ └─ Yes → Move logic to plugin service
416
+
417
+ Are you writing `as any`?
418
+ ├─ No → Good
419
+ └─ Yes → Run `donkeylabs generate`, use inferred types
420
+
421
+ Are you copying a Zod schema to a second file?
422
+ ├─ No → Good
423
+ └─ Yes → Create a shared .schemas.ts, import from there
424
+ ```
@@ -303,40 +303,51 @@ service: (ctx) => ({
303
303
  })
304
304
  ```
305
305
 
306
- ### DON'T: Create Unnecessary Files
306
+ ### DON'T: Create Unnecessary Files (But DO Split When Needed)
307
307
 
308
- ```ts
309
- // BAD: Over-engineered structure
310
- plugins/auth/
311
- ├── index.ts
308
+ The right structure depends on plugin size. Don't create files you don't need, but don't cram everything into one file either.
309
+
310
+ ```
311
+ // Small plugin (< 200 lines) — single file is fine
312
+ plugins/notifications/
313
+ ├── index.ts // Everything here
312
314
  ├── schema.ts
313
- ├── types/
314
- │ ├── index.ts
315
- │ ├── user.ts
316
- │ ├── session.ts
317
- │ └── token.ts
318
- ├── services/
319
- │ ├── index.ts
320
- │ ├── user.service.ts
321
- │ └── auth.service.ts
322
- ├── handlers/
323
- │ └── custom.handler.ts
324
- ├── middleware/
325
- │ └── auth.middleware.ts
326
- ├── utils/
327
- │ ├── hash.ts
328
- │ └── token.ts
329
315
  └── migrations/
330
- ```
331
316
 
332
- ```ts
333
- // GOOD: Keep it simple
317
+ // Medium plugin (200-500 lines) — extract service
318
+ plugins/orders/
319
+ ├── index.ts // Plugin definition + wiring only
320
+ ├── service.ts // Service class with business logic
321
+ ├── schema.ts
322
+ └── migrations/
323
+
324
+ // Large plugin (500+ lines) — full split
334
325
  plugins/auth/
335
- ├── index.ts // Everything in one file, or max 2-3
326
+ ├── index.ts // Plugin definition + wiring (< 100 lines)
327
+ ├── types.ts // Interfaces, type aliases
328
+ ├── service.ts // Service class with business logic
329
+ ├── helpers.ts // Pure utility functions
330
+ ├── constants.ts // Configuration constants
336
331
  ├── schema.ts
337
332
  └── migrations/
338
333
  ```
339
334
 
335
+ **Avoid** deeply nested subdirectories (`types/`, `services/`, `utils/`). Flat files at the plugin root are enough:
336
+
337
+ ```ts
338
+ // BAD: Nested subdirectories with barrel exports
339
+ plugins/auth/types/index.ts
340
+ plugins/auth/types/user.ts
341
+ plugins/auth/services/index.ts
342
+ plugins/auth/services/auth.service.ts
343
+
344
+ // GOOD: Flat files at plugin root
345
+ plugins/auth/types.ts
346
+ plugins/auth/service.ts
347
+ ```
348
+
349
+ See [Code Organization Guide](./code-organization.md) for detailed rules on when and how to split.
350
+
340
351
  ### DON'T: Edit Generated Files
341
352
 
342
353
  ```ts
@@ -484,7 +495,7 @@ When adding a new feature:
484
495
  1. **Putting business logic in route handlers** - Use plugin services
485
496
  2. **Not using Zod validation** - Always validate input
486
497
  3. **Editing generated files** - Regenerate instead
487
- 4. **Creating too many files** - Keep plugins simple
498
+ 4. **Wrong file granularity** - Split at 200 lines, see [Code Organization Guide](./code-organization.md)
488
499
  5. **Not running gen:registry** - Do this after any plugin change
489
500
  6. **Manual auth/rate limit checks** - Use middleware
490
501
  7. **Console.log for logging** - Use `ctx.core.logger`