@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/CLAUDE.md +455 -0
- package/docs/database.md +815 -0
- package/docs/plugins.md +121 -0
- package/docs/testing.md +430 -0
- package/package.json +2 -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)
|