@donkeylabs/server 0.4.0 → 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.
- package/docs/database.md +815 -0
- package/docs/plugins.md +121 -0
- package/package.json +1 -1
package/docs/database.md
ADDED
|
@@ -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:
|