@donkeylabs/server 0.4.2 → 0.4.3

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,815 @@
1
+ # Database (Kysely)
2
+
3
+ This framework uses [Kysely](https://kysely.dev/) as its type-safe SQL query builder. Kysely provides compile-time type checking for all your database queries.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Setup](#setup)
8
+ - [Basic Queries](#basic-queries)
9
+ - [CRUD Operations](#crud-operations)
10
+ - [Joins and Relations](#joins-and-relations)
11
+ - [Transactions](#transactions)
12
+ - [Raw SQL](#raw-sql)
13
+ - [Migrations](#migrations)
14
+ - [Schema Generation](#schema-generation)
15
+ - [Best Practices](#best-practices)
16
+
17
+ ---
18
+
19
+ ## Setup
20
+
21
+ ### Database Connection
22
+
23
+ ```ts
24
+ // index.ts
25
+ import { AppServer } from "@donkeylabs/server";
26
+ import { Kysely } from "kysely";
27
+ import { BunSqliteDialect } from "kysely-bun-sqlite";
28
+ import { Database } from "bun:sqlite";
29
+
30
+ // SQLite (development)
31
+ const db = new Kysely<DB>({
32
+ dialect: new BunSqliteDialect({
33
+ database: new Database("app.db")
34
+ }),
35
+ });
36
+
37
+ // PostgreSQL (production)
38
+ import { PostgresDialect } from "kysely";
39
+ import { Pool } from "pg";
40
+
41
+ const db = new Kysely<DB>({
42
+ dialect: new PostgresDialect({
43
+ pool: new Pool({ connectionString: process.env.DATABASE_URL }),
44
+ }),
45
+ });
46
+
47
+ const server = new AppServer({ db, port: 3000 });
48
+ ```
49
+
50
+ ### Accessing the Database
51
+
52
+ The database is available in:
53
+
54
+ 1. **Route handlers** via `ctx.db`
55
+ 2. **Plugin services** via `ctx.db` (typed with plugin schema)
56
+ 3. **Plugin context** via `ctx.core.db` (global database)
57
+
58
+ ```ts
59
+ // In a route handler
60
+ router.route("users.list").typed({
61
+ handle: async (_, ctx) => {
62
+ const users = await ctx.db
63
+ .selectFrom("users")
64
+ .selectAll()
65
+ .execute();
66
+ return { users };
67
+ },
68
+ });
69
+
70
+ // In a plugin service
71
+ service: async (ctx) => ({
72
+ async getUsers() {
73
+ return ctx.db
74
+ .selectFrom("users")
75
+ .selectAll()
76
+ .execute();
77
+ },
78
+ }),
79
+ ```
80
+
81
+ ---
82
+
83
+ ## Basic Queries
84
+
85
+ ### SELECT
86
+
87
+ ```ts
88
+ // Select all columns
89
+ const users = await ctx.db
90
+ .selectFrom("users")
91
+ .selectAll()
92
+ .execute();
93
+
94
+ // Select specific columns
95
+ const users = await ctx.db
96
+ .selectFrom("users")
97
+ .select(["id", "name", "email"])
98
+ .execute();
99
+
100
+ // Select with alias
101
+ const users = await ctx.db
102
+ .selectFrom("users")
103
+ .select([
104
+ "id",
105
+ "name",
106
+ ctx.db.fn.count<number>("id").as("total"),
107
+ ])
108
+ .execute();
109
+
110
+ // Get single record (returns undefined if not found)
111
+ const user = await ctx.db
112
+ .selectFrom("users")
113
+ .selectAll()
114
+ .where("id", "=", userId)
115
+ .executeTakeFirst();
116
+
117
+ // Get single record (throws if not found)
118
+ const user = await ctx.db
119
+ .selectFrom("users")
120
+ .selectAll()
121
+ .where("id", "=", userId)
122
+ .executeTakeFirstOrThrow();
123
+ ```
124
+
125
+ ### WHERE Clauses
126
+
127
+ ```ts
128
+ // Simple equality
129
+ .where("status", "=", "active")
130
+
131
+ // Multiple conditions (AND)
132
+ .where("status", "=", "active")
133
+ .where("role", "=", "admin")
134
+
135
+ // OR conditions
136
+ .where((eb) =>
137
+ eb.or([
138
+ eb("status", "=", "active"),
139
+ eb("status", "=", "pending"),
140
+ ])
141
+ )
142
+
143
+ // IN clause
144
+ .where("status", "in", ["active", "pending"])
145
+
146
+ // LIKE (pattern matching)
147
+ .where("email", "like", "%@gmail.com")
148
+
149
+ // NULL checks
150
+ .where("deleted_at", "is", null)
151
+ .where("deleted_at", "is not", null)
152
+
153
+ // Comparison operators
154
+ .where("age", ">=", 18)
155
+ .where("created_at", ">", "2024-01-01")
156
+
157
+ // Complex conditions
158
+ .where((eb) =>
159
+ eb.and([
160
+ eb("status", "=", "active"),
161
+ eb.or([
162
+ eb("role", "=", "admin"),
163
+ eb("role", "=", "moderator"),
164
+ ]),
165
+ ])
166
+ )
167
+ ```
168
+
169
+ ### ORDER BY and LIMIT
170
+
171
+ ```ts
172
+ // Order by single column
173
+ .orderBy("created_at", "desc")
174
+
175
+ // Order by multiple columns
176
+ .orderBy("status", "asc")
177
+ .orderBy("created_at", "desc")
178
+
179
+ // Pagination
180
+ .limit(20)
181
+ .offset(40) // Skip first 40 records (page 3)
182
+ ```
183
+
184
+ ---
185
+
186
+ ## CRUD Operations
187
+
188
+ ### CREATE (INSERT)
189
+
190
+ ```ts
191
+ // Insert single record
192
+ const user = await ctx.db
193
+ .insertInto("users")
194
+ .values({
195
+ email: "user@example.com",
196
+ name: "John Doe",
197
+ created_at: new Date().toISOString(),
198
+ })
199
+ .returningAll()
200
+ .executeTakeFirstOrThrow();
201
+
202
+ // Insert without returning
203
+ await ctx.db
204
+ .insertInto("users")
205
+ .values({
206
+ email: "user@example.com",
207
+ name: "John Doe",
208
+ })
209
+ .execute();
210
+
211
+ // Insert multiple records
212
+ await ctx.db
213
+ .insertInto("users")
214
+ .values([
215
+ { email: "user1@example.com", name: "User 1" },
216
+ { email: "user2@example.com", name: "User 2" },
217
+ { email: "user3@example.com", name: "User 3" },
218
+ ])
219
+ .execute();
220
+
221
+ // Insert with specific columns returned
222
+ const { id } = await ctx.db
223
+ .insertInto("users")
224
+ .values({ email: "user@example.com", name: "John" })
225
+ .returning(["id"])
226
+ .executeTakeFirstOrThrow();
227
+
228
+ // Insert or update (upsert) - PostgreSQL
229
+ await ctx.db
230
+ .insertInto("users")
231
+ .values({ email: "user@example.com", name: "John" })
232
+ .onConflict((oc) =>
233
+ oc.column("email").doUpdateSet({ name: "John Updated" })
234
+ )
235
+ .execute();
236
+
237
+ // Insert or ignore - SQLite
238
+ await ctx.db
239
+ .insertInto("users")
240
+ .values({ email: "user@example.com", name: "John" })
241
+ .onConflict((oc) => oc.column("email").doNothing())
242
+ .execute();
243
+ ```
244
+
245
+ ### READ (SELECT)
246
+
247
+ ```ts
248
+ // List with pagination
249
+ async function listUsers(page: number, limit: number = 20) {
250
+ const offset = (page - 1) * limit;
251
+
252
+ const [users, countResult] = await Promise.all([
253
+ ctx.db
254
+ .selectFrom("users")
255
+ .selectAll()
256
+ .orderBy("created_at", "desc")
257
+ .limit(limit)
258
+ .offset(offset)
259
+ .execute(),
260
+ ctx.db
261
+ .selectFrom("users")
262
+ .select(ctx.db.fn.count<number>("id").as("count"))
263
+ .executeTakeFirst(),
264
+ ]);
265
+
266
+ return {
267
+ users,
268
+ total: countResult?.count ?? 0,
269
+ page,
270
+ totalPages: Math.ceil((countResult?.count ?? 0) / limit),
271
+ };
272
+ }
273
+
274
+ // Find by ID
275
+ async function findById(id: number) {
276
+ return ctx.db
277
+ .selectFrom("users")
278
+ .selectAll()
279
+ .where("id", "=", id)
280
+ .executeTakeFirst();
281
+ }
282
+
283
+ // Find by unique field
284
+ async function findByEmail(email: string) {
285
+ return ctx.db
286
+ .selectFrom("users")
287
+ .selectAll()
288
+ .where("email", "=", email)
289
+ .executeTakeFirst();
290
+ }
291
+
292
+ // Search with filters
293
+ async function searchUsers(filters: {
294
+ search?: string;
295
+ status?: string;
296
+ role?: string;
297
+ }) {
298
+ let query = ctx.db.selectFrom("users").selectAll();
299
+
300
+ if (filters.search) {
301
+ query = query.where((eb) =>
302
+ eb.or([
303
+ eb("name", "like", `%${filters.search}%`),
304
+ eb("email", "like", `%${filters.search}%`),
305
+ ])
306
+ );
307
+ }
308
+
309
+ if (filters.status) {
310
+ query = query.where("status", "=", filters.status);
311
+ }
312
+
313
+ if (filters.role) {
314
+ query = query.where("role", "=", filters.role);
315
+ }
316
+
317
+ return query.orderBy("created_at", "desc").execute();
318
+ }
319
+ ```
320
+
321
+ ### UPDATE
322
+
323
+ ```ts
324
+ // Update single record
325
+ const user = await ctx.db
326
+ .updateTable("users")
327
+ .set({
328
+ name: "Jane Doe",
329
+ updated_at: new Date().toISOString(),
330
+ })
331
+ .where("id", "=", userId)
332
+ .returningAll()
333
+ .executeTakeFirstOrThrow();
334
+
335
+ // Update without returning
336
+ await ctx.db
337
+ .updateTable("users")
338
+ .set({ status: "inactive" })
339
+ .where("id", "=", userId)
340
+ .execute();
341
+
342
+ // Update multiple records
343
+ await ctx.db
344
+ .updateTable("users")
345
+ .set({ status: "inactive" })
346
+ .where("last_login", "<", thirtyDaysAgo)
347
+ .execute();
348
+
349
+ // Increment a value
350
+ await ctx.db
351
+ .updateTable("posts")
352
+ .set((eb) => ({
353
+ view_count: eb("view_count", "+", 1),
354
+ }))
355
+ .where("id", "=", postId)
356
+ .execute();
357
+
358
+ // Conditional update
359
+ await ctx.db
360
+ .updateTable("orders")
361
+ .set({ status: "shipped" })
362
+ .where("status", "=", "processing")
363
+ .where("created_at", "<", oneDayAgo)
364
+ .execute();
365
+ ```
366
+
367
+ ### DELETE
368
+
369
+ ```ts
370
+ // Delete single record
371
+ await ctx.db
372
+ .deleteFrom("users")
373
+ .where("id", "=", userId)
374
+ .execute();
375
+
376
+ // Delete with returning (get deleted record)
377
+ const deleted = await ctx.db
378
+ .deleteFrom("users")
379
+ .where("id", "=", userId)
380
+ .returningAll()
381
+ .executeTakeFirst();
382
+
383
+ // Delete multiple records
384
+ await ctx.db
385
+ .deleteFrom("sessions")
386
+ .where("expires_at", "<", new Date().toISOString())
387
+ .execute();
388
+
389
+ // Soft delete pattern
390
+ await ctx.db
391
+ .updateTable("users")
392
+ .set({ deleted_at: new Date().toISOString() })
393
+ .where("id", "=", userId)
394
+ .execute();
395
+ ```
396
+
397
+ ---
398
+
399
+ ## Joins and Relations
400
+
401
+ ### INNER JOIN
402
+
403
+ ```ts
404
+ // Get posts with author info
405
+ const posts = await ctx.db
406
+ .selectFrom("posts")
407
+ .innerJoin("users", "users.id", "posts.author_id")
408
+ .select([
409
+ "posts.id",
410
+ "posts.title",
411
+ "posts.content",
412
+ "users.name as author_name",
413
+ "users.email as author_email",
414
+ ])
415
+ .execute();
416
+ ```
417
+
418
+ ### LEFT JOIN
419
+
420
+ ```ts
421
+ // Get users with their posts (users without posts included)
422
+ const usersWithPosts = await ctx.db
423
+ .selectFrom("users")
424
+ .leftJoin("posts", "posts.author_id", "users.id")
425
+ .select([
426
+ "users.id",
427
+ "users.name",
428
+ "posts.id as post_id",
429
+ "posts.title as post_title",
430
+ ])
431
+ .execute();
432
+ ```
433
+
434
+ ### Multiple Joins
435
+
436
+ ```ts
437
+ // Get orders with customer and product info
438
+ const orders = await ctx.db
439
+ .selectFrom("orders")
440
+ .innerJoin("users", "users.id", "orders.customer_id")
441
+ .innerJoin("order_items", "order_items.order_id", "orders.id")
442
+ .innerJoin("products", "products.id", "order_items.product_id")
443
+ .select([
444
+ "orders.id as order_id",
445
+ "orders.total",
446
+ "users.name as customer_name",
447
+ "products.name as product_name",
448
+ "order_items.quantity",
449
+ ])
450
+ .execute();
451
+ ```
452
+
453
+ ### Subqueries
454
+
455
+ ```ts
456
+ // Get users with post count
457
+ const users = await ctx.db
458
+ .selectFrom("users")
459
+ .select([
460
+ "users.id",
461
+ "users.name",
462
+ (eb) =>
463
+ eb
464
+ .selectFrom("posts")
465
+ .select(eb.fn.count<number>("id").as("count"))
466
+ .where("posts.author_id", "=", eb.ref("users.id"))
467
+ .as("post_count"),
468
+ ])
469
+ .execute();
470
+ ```
471
+
472
+ ---
473
+
474
+ ## Transactions
475
+
476
+ ```ts
477
+ // Basic transaction
478
+ const result = await ctx.db.transaction().execute(async (trx) => {
479
+ // All queries use `trx` instead of `ctx.db`
480
+ const user = await trx
481
+ .insertInto("users")
482
+ .values({ email: "user@example.com", name: "John" })
483
+ .returningAll()
484
+ .executeTakeFirstOrThrow();
485
+
486
+ await trx
487
+ .insertInto("user_profiles")
488
+ .values({ user_id: user.id, bio: "Hello world" })
489
+ .execute();
490
+
491
+ return user;
492
+ });
493
+
494
+ // Transaction with rollback on error
495
+ async function transferFunds(fromId: number, toId: number, amount: number) {
496
+ return ctx.db.transaction().execute(async (trx) => {
497
+ // Deduct from sender
498
+ const sender = await trx
499
+ .updateTable("accounts")
500
+ .set((eb) => ({ balance: eb("balance", "-", amount) }))
501
+ .where("id", "=", fromId)
502
+ .where("balance", ">=", amount) // Ensure sufficient funds
503
+ .returningAll()
504
+ .executeTakeFirst();
505
+
506
+ if (!sender) {
507
+ throw new Error("Insufficient funds");
508
+ }
509
+
510
+ // Add to receiver
511
+ await trx
512
+ .updateTable("accounts")
513
+ .set((eb) => ({ balance: eb("balance", "+", amount) }))
514
+ .where("id", "=", toId)
515
+ .execute();
516
+
517
+ // Log the transfer
518
+ await trx
519
+ .insertInto("transfers")
520
+ .values({
521
+ from_id: fromId,
522
+ to_id: toId,
523
+ amount,
524
+ created_at: new Date().toISOString(),
525
+ })
526
+ .execute();
527
+
528
+ return { success: true };
529
+ });
530
+ }
531
+ ```
532
+
533
+ ---
534
+
535
+ ## Raw SQL
536
+
537
+ Use raw SQL sparingly, only when Kysely's query builder doesn't support your use case.
538
+
539
+ ```ts
540
+ import { sql } from "kysely";
541
+
542
+ // Raw expression in SELECT
543
+ const users = await ctx.db
544
+ .selectFrom("users")
545
+ .select([
546
+ "id",
547
+ "name",
548
+ sql<string>`UPPER(${sql.ref("email")})`.as("email_upper"),
549
+ ])
550
+ .execute();
551
+
552
+ // Raw WHERE condition
553
+ const users = await ctx.db
554
+ .selectFrom("users")
555
+ .selectAll()
556
+ .where(sql`LOWER(email) = ${email.toLowerCase()}`)
557
+ .execute();
558
+
559
+ // Full raw query (avoid if possible)
560
+ const result = await sql<{ count: number }>`
561
+ SELECT COUNT(*) as count
562
+ FROM users
563
+ WHERE created_at > ${startDate}
564
+ `.execute(ctx.db);
565
+ ```
566
+
567
+ ---
568
+
569
+ ## Migrations
570
+
571
+ ### Creating Migrations
572
+
573
+ Migrations are TypeScript files in the plugin's `migrations/` folder:
574
+
575
+ ```ts
576
+ // plugins/users/migrations/001_create_users.ts
577
+ import { Kysely, sql } from "kysely";
578
+
579
+ export async function up(db: Kysely<any>): Promise<void> {
580
+ await db.schema
581
+ .createTable("users")
582
+ .ifNotExists()
583
+ .addColumn("id", "integer", (c) => c.primaryKey().autoIncrement())
584
+ .addColumn("email", "text", (c) => c.notNull().unique())
585
+ .addColumn("name", "text", (c) => c.notNull())
586
+ .addColumn("password_hash", "text")
587
+ .addColumn("status", "text", (c) => c.defaultTo("active"))
588
+ .addColumn("created_at", "text", (c) =>
589
+ c.defaultTo(sql`CURRENT_TIMESTAMP`)
590
+ )
591
+ .addColumn("updated_at", "text")
592
+ .execute();
593
+
594
+ // Create index
595
+ await db.schema
596
+ .createIndex("idx_users_email")
597
+ .on("users")
598
+ .column("email")
599
+ .execute();
600
+ }
601
+
602
+ export async function down(db: Kysely<any>): Promise<void> {
603
+ await db.schema.dropTable("users").ifExists().execute();
604
+ }
605
+ ```
606
+
607
+ ### Common Schema Operations
608
+
609
+ ```ts
610
+ // Add column
611
+ await db.schema
612
+ .alterTable("users")
613
+ .addColumn("phone", "text")
614
+ .execute();
615
+
616
+ // Rename column (PostgreSQL)
617
+ await db.schema
618
+ .alterTable("users")
619
+ .renameColumn("name", "full_name")
620
+ .execute();
621
+
622
+ // Drop column
623
+ await db.schema
624
+ .alterTable("users")
625
+ .dropColumn("legacy_field")
626
+ .execute();
627
+
628
+ // Create index
629
+ await db.schema
630
+ .createIndex("idx_posts_author")
631
+ .on("posts")
632
+ .column("author_id")
633
+ .execute();
634
+
635
+ // Create unique index
636
+ await db.schema
637
+ .createIndex("idx_users_email_unique")
638
+ .on("users")
639
+ .column("email")
640
+ .unique()
641
+ .execute();
642
+
643
+ // Create composite index
644
+ await db.schema
645
+ .createIndex("idx_orders_customer_date")
646
+ .on("orders")
647
+ .columns(["customer_id", "created_at"])
648
+ .execute();
649
+
650
+ // Foreign key
651
+ await db.schema
652
+ .alterTable("posts")
653
+ .addForeignKeyConstraint(
654
+ "fk_posts_author",
655
+ ["author_id"],
656
+ "users",
657
+ ["id"]
658
+ )
659
+ .onDelete("cascade")
660
+ .execute();
661
+ ```
662
+
663
+ ---
664
+
665
+ ## Schema Generation
666
+
667
+ After creating migrations, generate TypeScript types:
668
+
669
+ ```sh
670
+ # Generate types for a plugin
671
+ bunx donkeylabs generate
672
+
673
+ # Or for a specific plugin
674
+ bun scripts/generate-types.ts <plugin-name>
675
+ ```
676
+
677
+ This creates `schema.ts` with full type definitions:
678
+
679
+ ```ts
680
+ // plugins/users/schema.ts (auto-generated)
681
+ export interface DB {
682
+ users: {
683
+ id: number;
684
+ email: string;
685
+ name: string;
686
+ password_hash: string | null;
687
+ status: string;
688
+ created_at: string;
689
+ updated_at: string | null;
690
+ };
691
+ }
692
+ ```
693
+
694
+ ---
695
+
696
+ ## Best Practices
697
+
698
+ ### 1. Always Use Type-Safe Queries
699
+
700
+ ```ts
701
+ // GOOD: Type-safe with autocomplete
702
+ const user = await ctx.db
703
+ .selectFrom("users")
704
+ .select(["id", "email", "name"]) // Autocomplete works!
705
+ .where("id", "=", userId)
706
+ .executeTakeFirst();
707
+
708
+ // BAD: Raw SQL loses type safety
709
+ const user = await sql`SELECT * FROM users WHERE id = ${userId}`.execute(ctx.db);
710
+ ```
711
+
712
+ ### 2. Keep Database Logic in Plugins
713
+
714
+ ```ts
715
+ // GOOD: Database logic in plugin service
716
+ export const usersPlugin = createPlugin.withSchema<DB>().define({
717
+ name: "users",
718
+ service: async (ctx) => ({
719
+ async findByEmail(email: string) {
720
+ return ctx.db
721
+ .selectFrom("users")
722
+ .selectAll()
723
+ .where("email", "=", email)
724
+ .executeTakeFirst();
725
+ },
726
+ }),
727
+ });
728
+
729
+ // BAD: Database logic in route handler
730
+ router.route("user.get").typed({
731
+ handle: async (input, ctx) => {
732
+ // Move this to a plugin!
733
+ return ctx.db.selectFrom("users").where("email", "=", input.email)...
734
+ },
735
+ });
736
+ ```
737
+
738
+ ### 3. Use Transactions for Multi-Step Operations
739
+
740
+ ```ts
741
+ // GOOD: Atomic operation
742
+ await ctx.db.transaction().execute(async (trx) => {
743
+ await trx.insertInto("orders").values(order).execute();
744
+ await trx.insertInto("order_items").values(items).execute();
745
+ await trx.updateTable("inventory").set(...).execute();
746
+ });
747
+
748
+ // BAD: Non-atomic (can leave inconsistent state)
749
+ await ctx.db.insertInto("orders").values(order).execute();
750
+ await ctx.db.insertInto("order_items").values(items).execute();
751
+ await ctx.db.updateTable("inventory").set(...).execute();
752
+ ```
753
+
754
+ ### 4. Use Parameterized Queries (Automatic with Kysely)
755
+
756
+ Kysely automatically parameterizes all values, preventing SQL injection:
757
+
758
+ ```ts
759
+ // Safe - Kysely parameterizes automatically
760
+ const user = await ctx.db
761
+ .selectFrom("users")
762
+ .where("email", "=", userInput) // userInput is safely escaped
763
+ .executeTakeFirst();
764
+
765
+ // Also safe
766
+ .where("email", "like", `%${searchTerm}%`)
767
+ ```
768
+
769
+ ### 5. Prefer `executeTakeFirst` Over Array Access
770
+
771
+ ```ts
772
+ // GOOD: Clear intent, proper typing
773
+ const user = await ctx.db
774
+ .selectFrom("users")
775
+ .selectAll()
776
+ .where("id", "=", id)
777
+ .executeTakeFirst(); // Returns User | undefined
778
+
779
+ // BAD: Unnecessary array, less clear
780
+ const [user] = await ctx.db
781
+ .selectFrom("users")
782
+ .selectAll()
783
+ .where("id", "=", id)
784
+ .execute();
785
+ ```
786
+
787
+ ### 6. Use `executeTakeFirstOrThrow` When Record Must Exist
788
+
789
+ ```ts
790
+ // When you expect the record to exist
791
+ const user = await ctx.db
792
+ .selectFrom("users")
793
+ .selectAll()
794
+ .where("id", "=", id)
795
+ .executeTakeFirstOrThrow(); // Throws if not found
796
+
797
+ // Handle gracefully when it might not exist
798
+ const user = await ctx.db
799
+ .selectFrom("users")
800
+ .selectAll()
801
+ .where("id", "=", id)
802
+ .executeTakeFirst();
803
+
804
+ if (!user) {
805
+ throw new NotFoundError("User not found");
806
+ }
807
+ ```
808
+
809
+ ---
810
+
811
+ ## Resources
812
+
813
+ - [Kysely Documentation](https://kysely.dev/)
814
+ - [Kysely API Reference](https://kysely-org.github.io/kysely-apidoc/)
815
+ - [Kysely GitHub](https://github.com/kysely-org/kysely)
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "type": "module",
5
5
  "description": "Type-safe plugin system for building RPC-style APIs with Bun",
6
6
  "main": "./src/index.ts",