@gallopsystems/agent-skills 1.13.0 → 1.15.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.
@@ -17,7 +17,21 @@ Use this skill when:
17
17
 
18
18
  ## Reference Files
19
19
 
20
- For detailed examples, see these topic-focused reference files:
20
+ This skill is split into topic guides — read the [Core Principles](#core-principles) below, then open the guide matching what you're doing:
21
+
22
+ - [expression-builder.md](references/expression-builder.md) — the ExpressionBuilder (`eb`) foundation, `eb.val` vs `eb.lit`, standalone `eb`, conditional expression arrays, string concatenation
23
+ - [query-patterns.md](references/query-patterns.md) — SELECT, WHERE clauses, JOINs (incl. callback format), aggregations, ORDER BY, CTEs, JSON aggregation
24
+ - [json-jsonb-arrays.md](references/json-jsonb-arrays.md) — JSONB columns, array columns, querying arrays/JSONB, JSONPath, `$if`, relations (jsonArrayFrom/jsonObjectFrom), reusable helpers, splitting build/execute, subqueries, INSERT/UPDATE
25
+ - [window-functions.md](references/window-functions.md) — ROW_NUMBER/RANK, LAG/LEAD, windowed aggregates, frames
26
+ - [set-lateral-locking.md](references/set-lateral-locking.md) — set operations (UNION/INTERSECT/EXCEPT), LATERAL joins, row locking (FOR UPDATE / SKIP LOCKED)
27
+ - [full-text-and-grouping.md](references/full-text-and-grouping.md) — full-text search (tsvector/tsquery), advanced grouping (ROLLUP/CUBE/GROUPING SETS)
28
+ - [migrations-and-codegen.md](references/migrations-and-codegen.md) — migrations (config, commands, file structure, column types, gotchas) and type generation
29
+ - [common-pitfalls.md](references/common-pitfalls.md) — the eight pitfalls (raw `sql`, forgetting `.execute()`, `whereRef`, typed function returns, FK indexing, typed `sql` literals, DATE timezones, the `between` operator)
30
+ - [helpers-and-extending.md](references/helpers-and-extending.md) — PostgreSQL helpers summary, `mergeAction`, custom helper functions, custom expression classes
31
+ - [deep-types.md](references/deep-types.md) — fixing the "excessively deep types" error with `$assertType`
32
+ - [seeding-pattern.md](references/seeding-pattern.md) — idempotent preview/dev seeding: factories shared by tests + seeds, monotonic-counter uniqueness, order-independence, scenes built in target state, the validity harness, preview login
33
+
34
+ Runnable, copy-pasteable query examples live alongside as `.ts` files:
21
35
 
22
36
  - [select-where.ts](references/select-where.ts) - Basic SELECT patterns, WHERE clauses, AND/OR, BETWEEN, ANY
23
37
  - [joins.ts](references/joins.ts) - Simple joins, callback joins, subquery joins, cross joins, lateral joins
@@ -35,1247 +49,10 @@ For detailed examples, see these topic-focused reference files:
35
49
 
36
50
  ## Core Principles
37
51
 
38
- 1. **Prefer Kysely methods over raw SQL**: Almost everything you can do in SQL, you can do in Kysely without `sql``
52
+ 1. **Always use Kysely's query builder — never reach for raw `sql`**: Almost anything expressible in SQL is expressible type-safely through Kysely's methods and the ExpressionBuilder (`eb`); raw `sql`` throws away the type safety this stack depends on. Treat it as a true last resort — only when Kysely genuinely cannot express the query (and type the template literal when you must). Being unsure how to do something is the cue to check the reference guides above, **not** to drop to raw SQL.
39
53
  2. **Use the ExpressionBuilder (eb)**: The `eb` parameter in callbacks is the foundation of type-safe query building
40
54
  3. **Let TypeScript guide you**: If it compiles, it's likely correct SQL
41
55
 
42
- ## ExpressionBuilder (eb) - The Foundation
43
-
44
- The `eb` parameter in select/where callbacks provides all expression methods:
45
-
46
- ```typescript
47
- .select((eb) => [
48
- eb.ref("column").as("alias"), // Column reference
49
- eb.fn<string>("upper", [eb.ref("email")]), // Function call (typed!)
50
- eb.fn.count("id").as("count"), // Aggregate function
51
- eb.fn.sum("amount").as("total"), // SUM
52
- eb.fn.avg("rating").as("avgRating"), // AVG
53
- eb.fn.coalesce("nullable_col", eb.val(0)), // COALESCE
54
- eb.case().when("status", "=", "active") // CASE expression
55
- .then("Active").else("Inactive").end(),
56
- eb("quantity", "*", eb.ref("unit_price")), // Binary expression
57
- eb.exists(subquery), // EXISTS
58
- eb.not(expression), // NOT / negation
59
- eb.cast(eb.val(" "), "text"), // Cast value to type
60
- eb.and([...]), // AND conditions
61
- eb.or([...]), // OR conditions
62
- ])
63
- ```
64
-
65
- ### eb.val() vs eb.lit()
66
-
67
- ```typescript
68
- // eb.val() - Creates a parameterized value ($1, $2, etc.) - PREFERRED for user input
69
- // Note: eb.val() alone may fail with "could not determine data type of parameter"
70
- // Use eb.cast(eb.val(...), "text") for string values in function arguments
71
- eb.val("user input") // Becomes: $1 with parameter "user input"
72
- eb.cast(eb.val("safe"), "text") // Becomes: $1::text - always works
73
-
74
- // eb.lit() - Creates a literal value in SQL
75
- // ONLY accepts: numbers, booleans, null - NOT strings (throws "unsafe immediate value")
76
- eb.lit(1) // Becomes: 1 (directly in SQL)
77
- eb.lit(true) // Becomes: true
78
- eb.lit(null) // Becomes: NULL
79
-
80
- // For string literals, use sql`` template instead
81
- sql`'active'` // Becomes: 'active' (directly in SQL)
82
- sql<string>`'label'` // Typed string literal
83
- ```
84
-
85
- ### Standalone ExpressionBuilder
86
-
87
- For reusable helpers outside query callbacks:
88
-
89
- ```typescript
90
- import { expressionBuilder } from "kysely";
91
- import type { DB } from "./db.d.ts";
92
-
93
- // Create standalone expression builder
94
- const eb = expressionBuilder<DB, "user">();
95
-
96
- // Use in helper functions
97
- function isActiveUser() {
98
- return eb.and([
99
- eb("is_active", "=", true),
100
- eb("role", "!=", "banned"),
101
- ]);
102
- }
103
- ```
104
-
105
- ### Conditional Expressions with Arrays
106
-
107
- Build dynamic filters by collecting expressions:
108
-
109
- ```typescript
110
- .where((eb) => {
111
- const filters: Expression<SqlBool>[] = [];
112
-
113
- if (firstName) filters.push(eb("first_name", "=", firstName));
114
- if (lastName) filters.push(eb("last_name", "=", lastName));
115
- if (minAge) filters.push(eb("age", ">=", minAge));
116
-
117
- // Combine all filters with AND (empty array = no filter)
118
- return eb.and(filters);
119
- })
120
- ```
121
-
122
- ## String Concatenation
123
-
124
- Use the `||` operator with `sql` template for clean string concatenation:
125
-
126
- ```typescript
127
- // RECOMMENDED - Clean and type-safe with eb.ref()
128
- .select((eb) => [
129
- sql<string>`${eb.ref("first_name")} || ' ' || ${eb.ref("last_name")}`.as("full_name"),
130
- ])
131
- // Output: "first_name" || ' ' || "last_name"
132
-
133
- // ALTERNATIVE - Pure eb() chaining (parameterized literals)
134
- .select((eb) => [
135
- eb(eb("first_name", "||", " "), "||", eb.ref("last_name")).as("full_name"),
136
- ])
137
- // Output: "first_name" || $1 || "last_name"
138
-
139
- // VERBOSE - concat() function (avoid unless you need NULL handling)
140
- .select((eb) => [
141
- eb.fn<string>("concat", [
142
- eb.ref("first_name"),
143
- eb.cast(eb.val(" "), "text"),
144
- eb.ref("last_name"),
145
- ]).as("full_name"),
146
- ])
147
- ```
148
-
149
- **Note**: `concat()` treats NULL as empty string, while `||` propagates NULL. Use `concat()` only when you need that NULL behavior.
150
-
151
- ## Query Patterns
152
-
153
- ### Basic SELECT
154
-
155
- ```typescript
156
- // Select all columns
157
- const users = await db.selectFrom("user").selectAll().execute();
158
-
159
- // Select specific columns with aliases
160
- const users = await db
161
- .selectFrom("user")
162
- .select(["id", "email", "first_name as firstName"])
163
- .execute();
164
-
165
- // Single row (returns T | undefined)
166
- const user = await db.selectFrom("user").selectAll()
167
- .where("id", "=", userId).executeTakeFirst();
168
-
169
- // Single row that must exist (throws if not found)
170
- const user = await db.selectFrom("user").selectAll()
171
- .where("id", "=", userId).executeTakeFirstOrThrow();
172
- ```
173
-
174
- ### WHERE Clauses
175
-
176
- ```typescript
177
- // Equality, comparison, IN, LIKE
178
- .where("status", "=", "active")
179
- .where("price", ">", 100)
180
- .where("role", "in", ["admin", "manager"])
181
- .where("name", "like", "%search%")
182
- .where("deleted_at", "is", null)
183
-
184
- // BETWEEN - use eb.between(), NOT the "between" operator (see Pitfall #8)
185
- .where((eb) => eb.between("age", 18, 65))
186
-
187
- // Multiple conditions (chained = AND)
188
- .where("is_active", "=", true)
189
- .where("role", "=", "admin")
190
-
191
- // OR conditions
192
- .where((eb) => eb.or([
193
- eb("role", "=", "admin"),
194
- eb("role", "=", "manager"),
195
- ]))
196
-
197
- // Complex AND/OR
198
- .where((eb) => eb.and([
199
- eb("is_active", "=", true),
200
- eb.or([
201
- eb("price", "<", 50),
202
- eb("stock", ">", 100),
203
- ]),
204
- ]))
205
- ```
206
-
207
- ### JOINs
208
-
209
- ```typescript
210
- // Inner join
211
- .innerJoin("order", "order.user_id", "user.id")
212
-
213
- // Left join
214
- .leftJoin("category", "category.id", "product.category_id")
215
-
216
- // Self-join with alias
217
- .selectFrom("category as c")
218
- .leftJoin("category as parent", "parent.id", "c.parent_id")
219
-
220
- // Multiple joins
221
- .innerJoin("order", "order.id", "order_item.order_id")
222
- .innerJoin("product", "product.id", "order_item.product_id")
223
- .innerJoin("user", "user.id", "order.user_id")
224
- ```
225
-
226
- ### Complex JOINs (Callback Format)
227
-
228
- Use the callback format when you need:
229
- - Multiple join conditions (composite keys)
230
- - Mixed column-to-column and column-to-literal comparisons
231
- - OR conditions within joins
232
- - Subquery joins (derived tables)
233
-
234
- **Join Builder Methods:**
235
- - `onRef(col1, op, col2)` - Column-to-column comparison
236
- - `on(col, op, value)` - Column-to-literal comparison
237
- - `on((eb) => ...)` - Complex expressions with OR logic
238
-
239
- ```typescript
240
- // Multi-condition join (composite key + filter)
241
- .leftJoin("invoice as i", (join) =>
242
- join
243
- .onRef("sp.service_provider_id", "=", "i.service_provider_id")
244
- .onRef("sp.year", "=", "i.year")
245
- .onRef("sp.month", "=", "i.month")
246
- .on("i.status", "!=", "invalidated")
247
- )
248
-
249
- // Join with OR conditions
250
- .leftJoin("order as o", (join) =>
251
- join
252
- .onRef("o.user_id", "=", "u.id")
253
- .on((eb) =>
254
- eb.or([
255
- eb("o.status", "=", "completed"),
256
- eb("o.status", "=", "shipped"),
257
- ])
258
- )
259
- )
260
-
261
- // Subquery join (derived table) - two callbacks
262
- .leftJoin(
263
- (eb) =>
264
- eb
265
- .selectFrom("order")
266
- .select((eb) => [
267
- "user_id",
268
- eb.fn.count("id").as("order_count"),
269
- eb.fn.max("created_at").as("last_order_at"),
270
- ])
271
- .groupBy("user_id")
272
- .as("order_stats"), // MUST have alias!
273
- (join) => join.onRef("order_stats.user_id", "=", "u.id")
274
- )
275
-
276
- // Cross join (always-true condition) - for joining aggregated CTEs
277
- .leftJoin("summary_cte", (join) =>
278
- join.on(sql`true`, "=", sql`true`)
279
- )
280
- ```
281
-
282
- ### Aggregations
283
-
284
- ```typescript
285
- .select((eb) => [
286
- "status",
287
- eb.fn.count("id").as("count"),
288
- eb.fn.sum("total_amount").as("totalAmount"),
289
- eb.fn.avg("total_amount").as("avgAmount"),
290
- ])
291
- .groupBy("status")
292
- .having((eb) => eb.fn.count("id"), ">", 5)
293
- ```
294
-
295
- #### FILTER (WHERE ...) on Aggregates
296
-
297
- PostgreSQL's `FILTER (WHERE ...)` clause is available on **all** aggregate function builders via `.filterWhere()`:
298
-
299
- ```typescript
300
- .select((eb) => [
301
- eb.fn.count("id").filterWhere("status", "=", "active").as("active_count"),
302
- eb.fn.countAll().filterWhere("role", "!=", "banned").as("non_banned"),
303
- eb.fn.sum("amount").filterWhere("type", "=", "credit").as("total_credits"),
304
- ])
305
-
306
- // Also works as the first argument to .having()
307
- .having(
308
- (eb) => eb.fn.countAll().filterWhere("status", "!=", "signed"),
309
- "=",
310
- 0
311
- )
312
- ```
313
-
314
- ### ORDER BY
315
-
316
- ```typescript
317
- // Simple ordering
318
- .orderBy("created_at", "desc")
319
- .orderBy("name", "asc")
320
-
321
- // NULLS FIRST / NULLS LAST - use order builder callback
322
- .orderBy("category_id", (ob) => ob.asc().nullsLast())
323
- .orderBy("priority", (ob) => ob.desc().nullsFirst())
324
-
325
- // Multiple columns - chain orderBy calls (array syntax is deprecated)
326
- .orderBy("category_id", "asc")
327
- .orderBy("price", "desc")
328
- .orderBy("name", "asc")
329
- ```
330
-
331
- ### CTEs (Common Table Expressions)
332
-
333
- Use CTEs for complex queries with multiple aggregation levels:
334
-
335
- ```typescript
336
- const result = await db
337
- .with("order_totals", (db) =>
338
- db.selectFrom("order")
339
- .innerJoin("user", "user.id", "order.user_id")
340
- .select((eb) => [
341
- "user.id as userId",
342
- "user.email",
343
- eb.fn.sum("order.total_amount").as("totalSpent"),
344
- eb.fn.count("order.id").as("orderCount"),
345
- ])
346
- .groupBy(["user.id", "user.email"])
347
- )
348
- .selectFrom("order_totals")
349
- .selectAll()
350
- .orderBy("totalSpent", "desc")
351
- .execute();
352
- ```
353
-
354
- ### JSON Aggregation (PostgreSQL)
355
-
356
- ```typescript
357
- import { jsonBuildObject } from "kysely/helpers/postgres";
358
- // Note: jsonAgg is accessed via eb.fn.jsonAgg(), not imported
359
-
360
- .with("tasks", (db) =>
361
- db.selectFrom("task")
362
- .leftJoin("user", "user.id", "task.assignee_id")
363
- .select((eb) => [
364
- "task.job_id",
365
- eb.fn.jsonAgg(
366
- jsonBuildObject({
367
- id: eb.ref("task.id"),
368
- status: eb.ref("task.status"),
369
- assignee: jsonBuildObject({
370
- id: eb.ref("user.id"),
371
- name: eb.fn<string>("concat", [
372
- eb.ref("user.first_name"),
373
- eb.cast(eb.val(" "), "text"),
374
- eb.ref("user.last_name"),
375
- ]),
376
- }),
377
- })
378
- )
379
- .filterWhere("task.id", "is not", null) // Filter nulls from left join
380
- .as("tasks"),
381
- ])
382
- .groupBy("task.job_id")
383
- )
384
- ```
385
-
386
- ## JSON, JSONB, and Array Handling
387
-
388
- ### JSONB Columns
389
-
390
- **NO `JSON.stringify` or `JSON.parse` needed!** The `pg` driver handles JSONB automatically:
391
-
392
- ```typescript
393
- // INSERT - pass objects directly
394
- await db
395
- .insertInto("user")
396
- .values({
397
- email: "test@example.com",
398
- metadata: { preferences: { theme: "dark" }, count: 42 },
399
- })
400
- .execute();
401
-
402
- // UPDATE - pass objects directly
403
- await db
404
- .updateTable("user")
405
- .set({
406
- metadata: { preferences: { theme: "light" } },
407
- })
408
- .where("id", "=", userId)
409
- .execute();
410
-
411
- // READ - returns parsed object, not string
412
- const user = await db
413
- .selectFrom("user")
414
- .select(["id", "metadata"])
415
- .executeTakeFirst();
416
- console.log(user.metadata.preferences.theme); // "dark" - already an object!
417
- ```
418
-
419
- ### Array Columns (text[], int[], etc.)
420
-
421
- **NO `JSON.stringify` needed for array columns!** The `pg` driver handles arrays natively:
422
-
423
- ```typescript
424
- // INSERT with array - pass array directly
425
- await db
426
- .insertInto("product")
427
- .values({
428
- name: "Product",
429
- tags: ["phone", "electronics", "premium"], // Direct array!
430
- })
431
- .execute();
432
-
433
- // READ - returns as native JavaScript array
434
- const product = await db
435
- .selectFrom("product")
436
- .select(["name", "tags"])
437
- .executeTakeFirst();
438
- console.log(product.tags); // ["phone", "electronics", "premium"]
439
-
440
- // UPDATE array
441
- await db
442
- .updateTable("product")
443
- .set({ tags: ["updated", "tags"] })
444
- .where("id", "=", productId)
445
- .execute();
446
- ```
447
-
448
- ### Querying Arrays
449
-
450
- ```typescript
451
- // Array contains all values (@>) - operator works natively!
452
- .where("tags", "@>", sql`ARRAY['phone', 'premium']::text[]`)
453
-
454
- // Arrays overlap (&&) - operator works natively!
455
- .where("tags", "&&", sql`ARRAY['premium', 'basic']::text[]`)
456
-
457
- // Array contains value (ANY) - type-safe with eb.fn
458
- .where((eb) => eb(sql`${searchTerm}`, "=", eb.fn("any", [eb.ref("tags")])))
459
- // eb.ref("tags") validates column exists - eb.ref("invalid") would be a TS error
460
- ```
461
-
462
- ### Querying JSONB
463
-
464
- ```typescript
465
- // Key exists (?) - operator works natively!
466
- .where("metadata", "?", "theme")
467
-
468
- // Any key exists (?|) - operator works natively!
469
- .where("metadata", "?|", sql`array['theme', 'language']`)
470
-
471
- // All keys exist (?&) - operator works natively!
472
- .where("metadata", "?&", sql`array['theme', 'notifications']`)
473
-
474
- // JSONB contains (@>) - operator works natively!
475
- .where("metadata", "@>", sql`'{"notifications": true}'::jsonb`)
476
-
477
- // Extract field as text (->> as operator) - type-safe!
478
- .where((eb) => eb(eb("metadata", "->>", "theme"), "=", "dark"))
479
- // eb("metadata", ...) validates column - eb("invalid", ...) would be TS error
480
-
481
- // Extract nested path (#>> still needs sql``)
482
- .where(sql`metadata#>>'{preferences,theme}'`, "=", "dark")
483
-
484
- // In SELECT - type-safe with eb()
485
- .select((eb) => [
486
- eb("metadata", "->", "preferences").as("prefs"), // Returns JSONB
487
- eb("metadata", "->>", "theme").as("theme"), // Returns text
488
- ])
489
- // Nested paths still need sql``
490
- .select(sql`metadata#>'{preferences,theme}'`.as("t")) // Nested as JSONB
491
- .select(sql<string>`metadata#>>'{a,b}'`.as("t")) // Nested as text
492
- ```
493
-
494
- ### JSONPath (PostgreSQL 12+)
495
-
496
- ```typescript
497
- // JSONPath match (@@) - works as native operator!
498
- .where("metadata", "@@", sql`'$.preferences.theme == "dark"'`)
499
-
500
- // JSONPath exists (@?) - NOT in Kysely's allowlist, use function instead
501
- // Use jsonb_path_exists() for type-safe column validation
502
- .where((eb) =>
503
- eb.fn("jsonb_path_exists", [eb.ref("metadata"), sql`'$.preferences.theme'`])
504
- )
505
- // eb.ref("metadata") validates column - eb.ref("invalid") would be TS error
506
-
507
- // Extract with JSONPath - type-safe with eb.fn
508
- .select((eb) => [
509
- "id",
510
- eb.fn("jsonb_path_query_first", [eb.ref("metadata"), sql`'$.preferences.theme'`]).as("theme"),
511
- ])
512
-
513
- // JSONPath with variables
514
- const searchValue = "dark";
515
- .where((eb) =>
516
- eb.fn("jsonb_path_exists", [
517
- eb.ref("metadata"),
518
- sql`'$.preferences.theme ? (@ == $val)'`,
519
- sql`jsonb_build_object('val', ${searchValue}::text)`,
520
- ])
521
- )
522
- ```
523
-
524
- ### Conditional Queries ($if)
525
-
526
- Use `$if()` for runtime-conditional query modifications:
527
-
528
- ```typescript
529
- const result = await db
530
- .selectFrom("user")
531
- .selectAll()
532
- .$if(!includeInactive, (qb) => qb.where("is_active", "=", true))
533
- .$if(includeMetadata, (qb) => qb.select("metadata"))
534
- .$if(!!searchTerm, (qb) => qb.where("name", "like", `%${searchTerm}%`))
535
- .$if(!!roleFilter, (qb) => qb.where("role", "in", roleFilter!))
536
- .execute();
537
- ```
538
-
539
- **Type behavior**: Columns added via `$if` become optional in the result type since inclusion isn't guaranteed at compile time.
540
-
541
- ### Relations (jsonArrayFrom / jsonObjectFrom)
542
-
543
- Kysely is NOT an ORM - it uses PostgreSQL's JSON functions for nested data:
544
-
545
- ```typescript
546
- import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
547
-
548
- // One-to-many: User with their orders
549
- const users = await db
550
- .selectFrom("user")
551
- .select((eb) => [
552
- "user.id",
553
- "user.email",
554
- jsonArrayFrom(
555
- eb
556
- .selectFrom("order")
557
- .select(["order.id", "order.status", "order.total_amount"])
558
- .whereRef("order.user_id", "=", "user.id")
559
- .orderBy("order.created_at", "desc")
560
- ).as("orders"),
561
- ])
562
- .execute();
563
-
564
- // Many-to-one: Product with its category
565
- const products = await db
566
- .selectFrom("product")
567
- .select((eb) => [
568
- "product.id",
569
- "product.name",
570
- jsonObjectFrom(
571
- eb
572
- .selectFrom("category")
573
- .select(["category.id", "category.name"])
574
- .whereRef("category.id", "=", "product.category_id")
575
- ).as("category"),
576
- ])
577
- .execute();
578
- ```
579
-
580
- **Critical: Use explicit `.select()` instead of `.selectAll()` with nested json helpers**
581
-
582
- When using `jsonObjectFrom` containing a nested `jsonArrayFrom` (or vice versa), using `selectAll("table")` breaks TypeScript's type inference. The result type becomes `unknown` or loses the nested structure, requiring `$castTo` to fix.
583
-
584
- ```typescript
585
- // WRONG - selectAll() breaks type inference for nested json helpers
586
- const invoice = await db
587
- .selectFrom("invoices")
588
- .selectAll("invoices")
589
- .select((eb) => [
590
- jsonObjectFrom(
591
- eb
592
- .selectFrom("payment_plans")
593
- .selectAll() // ❌ This breaks type inference!
594
- .select((eb2) => [
595
- jsonArrayFrom(
596
- eb2.selectFrom("installments").selectAll()
597
- .whereRef("installments.plan_id", "=", "payment_plans.id")
598
- ).as("installments"),
599
- ])
600
- .whereRef("payment_plans.invoice_id", "=", "invoices.id")
601
- ).as("payment_plan"), // Type is unknown or broken
602
- ])
603
- .executeTakeFirst();
604
-
605
- // RIGHT - explicit select() preserves type inference
606
- const invoice = await db
607
- .selectFrom("invoices")
608
- .selectAll("invoices")
609
- .select((eb) => [
610
- jsonObjectFrom(
611
- eb
612
- .selectFrom("payment_plans")
613
- .select([ // ✅ Explicit columns!
614
- "payment_plans.id",
615
- "payment_plans.invoice_id",
616
- "payment_plans.notes",
617
- "payment_plans.created_at",
618
- ])
619
- .select((eb2) => [
620
- jsonArrayFrom(
621
- eb2.selectFrom("installments").selectAll()
622
- .whereRef("installments.plan_id", "=", "payment_plans.id")
623
- ).as("installments"),
624
- ])
625
- .whereRef("payment_plans.invoice_id", "=", "invoices.id")
626
- ).as("payment_plan"), // Type is properly inferred!
627
- ])
628
- .executeTakeFirst();
629
- ```
630
-
631
- **Why this happens**: Kysely's type inference for nested json helpers relies on tracking the selected columns through the query chain. `selectAll()` returns all columns dynamically, which confuses TypeScript when combined with additional `.select()` calls that add nested json helpers. Using explicit column names gives TypeScript the static information it needs.
632
-
633
- **Rule of thumb**: When combining `jsonObjectFrom`/`jsonArrayFrom` with nested json helpers, always use explicit `.select([...columns])` instead of `.selectAll()` on the subquery containing the nested helper.
634
-
635
- ### Reusable Helpers
636
-
637
- Create composable, type-safe helper functions using `Expression<T>`:
638
-
639
- ```typescript
640
- import { Expression, sql } from "kysely";
641
-
642
- // Helper that takes and returns Expression<string>
643
- function lower(expr: Expression<string>) {
644
- return sql<string>`lower(${expr})`;
645
- }
646
-
647
- // Use in queries
648
- .where(({ eb, ref }) => eb(lower(ref("email")), "=", email.toLowerCase()))
649
- ```
650
-
651
- ### Splitting Query Building and Execution
652
-
653
- Build queries without executing, useful for dynamic query construction:
654
-
655
- ```typescript
656
- // Build query (doesn't execute)
657
- let query = db
658
- .selectFrom("user")
659
- .select(["id", "email"]);
660
-
661
- // Add conditions dynamically
662
- if (role) {
663
- query = query.where("role", "=", role);
664
- }
665
- if (isActive !== undefined) {
666
- query = query.where("is_active", "=", isActive);
667
- }
668
-
669
- // Execute when ready
670
- const results = await query.execute();
671
-
672
- // Or compile to SQL without executing
673
- const compiled = query.compile();
674
- console.log(compiled.sql); // The SQL string
675
- console.log(compiled.parameters); // Bound parameters
676
- ```
677
-
678
- ### Subqueries
679
-
680
- ```typescript
681
- // Subquery in WHERE
682
- .where("id", "in",
683
- db.selectFrom("order").select("user_id").where("status", "=", "completed")
684
- )
685
-
686
- // EXISTS subquery
687
- .where((eb) =>
688
- eb.exists(
689
- db.selectFrom("review")
690
- .select(sql`1`.as("one"))
691
- .whereRef("review.product_id", "=", eb.ref("product.id"))
692
- )
693
- )
694
- ```
695
-
696
- ### INSERT Operations
697
-
698
- ```typescript
699
- // Single insert with returning
700
- const user = await db
701
- .insertInto("user")
702
- .values({ email: "test@example.com", first_name: "Test", last_name: "User" })
703
- .returning(["id", "email"])
704
- .executeTakeFirst();
705
-
706
- // Multiple rows
707
- await db
708
- .insertInto("user")
709
- .values([
710
- { email: "a@example.com", first_name: "A", last_name: "User" },
711
- { email: "b@example.com", first_name: "B", last_name: "User" },
712
- ])
713
- .execute();
714
-
715
- // Upsert (ON CONFLICT) - type-safe with expression builder
716
- await db
717
- .insertInto("product")
718
- .values({ sku: "ABC123", name: "Product", stock_quantity: 10 })
719
- .onConflict((oc) =>
720
- oc.column("sku").doUpdateSet((eb) => ({
721
- stock_quantity: eb("product.stock_quantity", "+", eb.ref("excluded.stock_quantity")),
722
- }))
723
- )
724
- .execute();
725
- // eb("product.invalid_column", ...) would be a TypeScript error!
726
-
727
- // Insert from SELECT
728
- await db
729
- .insertInto("archive")
730
- .columns(["user_id", "data", "archived_at"])
731
- .expression(
732
- db.selectFrom("user")
733
- .select(["id", "metadata", sql`now()`.as("archived_at")])
734
- .where("is_active", "=", false)
735
- )
736
- .execute();
737
- ```
738
-
739
- ### UPDATE Operations
740
-
741
- ```typescript
742
- // Simple update
743
- await db
744
- .updateTable("user")
745
- .set({ is_active: false })
746
- .where("id", "=", userId)
747
- .execute();
748
-
749
- // Update with expression
750
- await db
751
- .updateTable("product")
752
- .set((eb) => ({
753
- stock_quantity: eb("stock_quantity", "+", 10),
754
- }))
755
- .where("sku", "=", "ABC123")
756
- .returning(["id", "stock_quantity"])
757
- .executeTakeFirst();
758
- ```
759
-
760
- ## Window Functions
761
-
762
- Two builders cover almost everything. See [window-functions.ts](references/window-functions.ts) for the full set.
763
-
764
- ```typescript
765
- // Named window functions (no dedicated helper): eb.fn.agg<T>("NAME", [args])
766
- .select((eb) => [
767
- eb.fn.agg<number>("ROW_NUMBER")
768
- .over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
769
- .as("rank"),
770
- // LAG/LEAD args go in the array; wrap literals in sql.lit()
771
- eb.fn.agg<number | null>("LAG", ["total_amount", sql.lit(1)])
772
- .over((ob) => ob.partitionBy("user_id").orderBy("created_at"))
773
- .as("prev_amount"),
774
- ])
775
-
776
- // Windowed aggregates: .over() on sum/count/avg/min/max
777
- .select((eb) => [
778
- eb.fn.sum<number>("total_amount").over((ob) => ob.orderBy("created_at")).as("running_total"),
779
- eb.fn.avg<number>("price").over().as("grand_avg"), // empty OVER ()
780
- // .filterWhere() and .distinct() compose with .over()
781
- eb.fn.countAll<number>().filterWhere("status", "=", "completed")
782
- .over((ob) => ob.partitionBy("user_id")).as("completed_for_user"),
783
- ])
784
- ```
785
-
786
- **Filtering on a window result** (e.g. `row_number = 1`) needs a CTE/subquery — windows are computed after `WHERE`, so rank in a CTE then filter the outer query.
787
-
788
- **Window frames (`ROWS`/`RANGE BETWEEN`) require raw SQL.** Kysely's `OverBuilder` exposes only `partitionBy`/`orderBy` — there is no frame node in its AST at all (true through 0.28, 0.29, and `main`), and `.over()` rejects a raw `sql` argument. Frame support is the open feature request [kysely-org/kysely#505](https://github.com/kysely-org/kysely/issues/505). Write the frame as raw SQL but keep column refs typed via `eb.ref()`:
789
-
790
- ```typescript
791
- // Only the function name + frame keywords are raw; columns stay validated.
792
- sql<number>`avg(${eb.ref("total_amount")}) over (
793
- order by ${eb.ref("created_at")} rows between 2 preceding and current row
794
- )`.as("moving_avg_3")
795
- ```
796
-
797
- ## Set Operations (UNION / INTERSECT / EXCEPT)
798
-
799
- Combine two compatible queries. Plain forms dedupe; `*All` forms keep duplicates (and are cheaper). See [set-operations.ts](references/set-operations.ts).
800
-
801
- ```typescript
802
- db.selectFrom("order").select("user_id")
803
- .except(db.selectFrom("review").select("user_id")) // orders, never reviewed
804
- .execute();
805
- // .union/.unionAll, .intersect/.intersectAll, .except/.exceptAll
806
- // Both branches must select matching columns/names — align with `as` aliases.
807
- ```
808
-
809
- ## LATERAL Joins (PostgreSQL)
810
-
811
- A `LATERAL` subquery can reference earlier tables via `whereRef` and runs per outer row — the best tool for **top-N-per-group with a per-row LIMIT**. Use `*Lateral` + `join.onTrue()`. See [joins.ts](references/joins.ts).
812
-
813
- ```typescript
814
- // Each user's 3 most recent orders
815
- db.selectFrom("user as u")
816
- .innerJoinLateral(
817
- (eb) => eb.selectFrom("order as o")
818
- .select(["o.id", "o.total_amount", "o.created_at"])
819
- .whereRef("o.user_id", "=", "u.id")
820
- .orderBy("o.created_at", "desc").limit(3).as("recent"),
821
- (join) => join.onTrue()
822
- )
823
- .select(["u.email", "recent.id", "recent.total_amount"])
824
- .execute();
825
- // leftJoinLateral / crossJoinLateral also exist.
826
- ```
827
-
828
- ## Row Locking (FOR UPDATE / SKIP LOCKED)
829
-
830
- Pessimistic locks for read-modify-write and job queues (run inside a transaction). See [locking.ts](references/locking.ts).
831
-
832
- ```typescript
833
- // Job-queue worker: grab the next pending jobs, skipping rows other workers hold
834
- db.selectFrom("job").selectAll()
835
- .where("status", "=", "pending")
836
- .orderBy("created_at").limit(10)
837
- .forUpdate().skipLocked()
838
- .execute();
839
- // Lock strength: forKeyShare < forShare < forNoKeyUpdate < forUpdate
840
- // Wait behavior: .skipLocked() (skip) or .noWait() (error instead of blocking)
841
- ```
842
-
843
- ## Full-Text Search (PostgreSQL)
844
-
845
- `@@` is a real Kysely operator — keep it as the operator and put the FTS functions on each side as `sql` fragments. See [full-text-search.ts](references/full-text-search.ts).
846
-
847
- ```typescript
848
- db.selectFrom("document").selectAll()
849
- .where(
850
- sql`to_tsvector('english', ${sql.ref("body")})`,
851
- "@@",
852
- sql`websearch_to_tsquery('english', ${userInput})`,
853
- )
854
- .execute();
855
- // A stored tsvector column can be the typed LHS directly:
856
- // .where("search_vector", "@@", sql`plainto_tsquery('english', ${userInput})`)
857
- ```
858
-
859
- ## Advanced Grouping (ROLLUP / CUBE / GROUPING SETS)
860
-
861
- No builder exists — pass the grouping spec to `.groupBy()` as a `sql` fragment; the SELECT list stays typed. See [aggregations.ts](references/aggregations.ts).
862
-
863
- ```typescript
864
- .groupBy(sql`rollup("status")`) // hierarchical subtotals
865
- .groupBy(sql`cube("order_id", "product_id")`) // all combinations
866
- .groupBy(sql`grouping sets (("status"), ("user_id"), ())`) // explicit sets
867
- ```
868
-
869
- ## Migrations
870
-
871
- ### Configuration (kysely.config.ts)
872
-
873
- ```typescript
874
- import { PostgresDialect } from "kysely";
875
- import { defineConfig } from "kysely-ctl";
876
- import pg from "pg";
877
-
878
- export default defineConfig({
879
- dialect: new PostgresDialect({
880
- pool: new pg.Pool({
881
- connectionString: process.env.DATABASE_URL,
882
- }),
883
- }),
884
- migrations: {
885
- migrationFolder: "src/db/migrations",
886
- },
887
- seeds: {
888
- seedFolder: "src/db/seeds",
889
- },
890
- });
891
- ```
892
-
893
- ### Migration Commands
894
-
895
- ```bash
896
- npx kysely migrate:make migration-name # Create migration
897
- npx kysely migrate:latest # Run all pending migrations
898
- npx kysely migrate:down # Rollback last migration
899
- npx kysely seed make seed-name # Create seed
900
- npx kysely seed run # Run all seeds
901
- ```
902
-
903
- ### Migration File Structure
904
-
905
- ```typescript
906
- import type { Kysely } from "kysely";
907
- import { sql } from "kysely";
908
-
909
- // Always use Kysely<any> - migrations should be frozen in time
910
- export async function up(db: Kysely<any>): Promise<void> {
911
- await db.schema
912
- .createTable("user")
913
- .addColumn("id", "bigint", (col) => col.primaryKey().generatedAlwaysAsIdentity())
914
- .addColumn("email", "text", (col) => col.notNull().unique())
915
- .addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`))
916
- .execute();
917
-
918
- // IMPORTANT: Always index foreign key columns!
919
- await db.schema.createIndex("idx_order_user_id").on("order").column("user_id").execute();
920
- }
921
-
922
- export async function down(db: Kysely<any>): Promise<void> {
923
- await db.schema.dropTable("user").execute();
924
- }
925
- ```
926
-
927
- ### Recommended Column Types
928
-
929
- ```typescript
930
- // Primary keys: Use identity columns (SQL standard, prevents accidental ID conflicts)
931
- .addColumn("id", "bigint", (col) => col.primaryKey().generatedAlwaysAsIdentity())
932
- // NOT serial/bigserial - those allow manual ID inserts that can cause conflicts
933
-
934
- // Timestamps: Always use timestamptz (stores UTC, converts to client timezone)
935
- .addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`))
936
- // NOT timestamp - loses timezone information
937
-
938
- // Money: Use numeric with precision (exact decimal, no floating point errors)
939
- .addColumn("price", "numeric(10, 2)", (col) => col.notNull())
940
- // NOT float/real/double precision - those have rounding errors
941
-
942
- // Strings: Use text (no length limit, same performance as varchar)
943
- .addColumn("name", "text", (col) => col.notNull())
944
- // varchar(n) only if you need a hard length constraint
945
-
946
- // JSON: Use jsonb (binary, indexable, faster queries)
947
- .addColumn("metadata", "jsonb")
948
- // NOT json - stored as text, no indexing, slower
949
-
950
- // Foreign keys: Create indexes manually (PostgreSQL doesn't auto-index FKs)
951
- await db.schema.createIndex("idx_order_user_id").on("order").column("user_id").execute();
952
- ```
953
-
954
- ### Data Type Gotchas
955
-
956
- ```typescript
957
- // CORRECT - Space after comma in numeric types
958
- .addColumn("price", "numeric(10, 2)")
959
-
960
- // WRONG - Will fail with "invalid column data type"
961
- .addColumn("price", "numeric(10,2)")
962
-
963
- // For complex types, use sql template
964
- .addColumn("price", sql`numeric(10, 2)`)
965
- ```
966
-
967
- ### Migration Ordering Is Append-Only — Out-of-Order Timestamps Break Prod Deploys
968
-
969
- Kysely's migrator enforces a **strict append-only ledger**: it refuses to run any
970
- unexecuted migration whose timestamp sorts *before* the last-executed one
971
- (throwing `Corrupted migrations: previously executed migration ... is missing`).
972
- This bites when two branches each add a migration, and the one that merges *second*
973
- carries the *earlier* timestamp:
974
-
975
- ```
976
- branch A merges first → 1700000200000_drop_thing (runs in prod)
977
- branch B merges second → 1700000100000_add_table (timestamp is EARLIER)
978
- → 1700000100001_add_column
979
- ```
980
-
981
- Prod has already recorded `...200000_drop_thing` as executed. On the next deploy
982
- the `migrate` job sees two pending migrations that sort *before* it, throws
983
- "Corrupted migrations", and **exits non-zero**. If migrations run as a pre-deploy
984
- job (common on PaaS like DigitalOcean App Platform), the failed job fails the whole
985
- deploy and the platform **auto-rolls-back to the previous image** — so prod silently
986
- stays on stale code and every subsequent deploy fails the same way. A self-reinforcing
987
- loop that looks like a deploy/token problem but is really a migration-ledger problem.
988
-
989
- **Prevent it:** before merging a long-lived branch, check whether `main` has merged
990
- any migration with a *later* timestamp than yours. If so, regenerate your migration's
991
- timestamp so it sorts last (`migrate:make` again, or rename the file) **before it
992
- merges** — only safe while the migration has not yet run in any shared DB. Never
993
- re-stamp a migration that prod has already executed; that forces it to re-run.
994
-
995
- **Fix it once prod is wedged:** reconcile the ledger so the executed set is a clean
996
- *prefix* again, then let the normal strict migrate job run. Do **not** reach for
997
- `allowUnorderedMigrations: true` — it works (kysely-ctl spreads the `migrations`
998
- config into the `Migrator`), but it permanently weakens the ordering guard to paper
999
- over one bad state. Instead, surgically remove the prematurely-recorded row from the
1000
- migration ledger table (default `kysely_migration`):
1001
-
1002
- ```sql
1003
- -- prod ledger has the later-timestamp migration recorded, blocking the two earlier ones
1004
- DELETE FROM kysely_migration WHERE name = '1700000200000_drop_thing';
1005
- ```
1006
-
1007
- Now `add_table → add_column → drop_thing` are all pending in true timestamp order, and
1008
- the next deploy's strict migrate job applies them cleanly. This only works when the
1009
- removed migration is **idempotent to re-run** (e.g. a `dropTable`/`dropColumn` written
1010
- with `ifExists`, so re-applying it after the others is a safe no-op). Verify the
1011
- ledger is a contiguous prefix after the DELETE, and prefer letting the deploy's own
1012
- migrate job re-apply rather than running migrations from a laptop against prod.
1013
-
1014
- ## Type Generation
1015
-
1016
- Use `kysely-codegen` to generate types from your database:
1017
-
1018
- ```bash
1019
- npx kysely-codegen --url "postgresql://..." --out-file src/db/db.d.ts
1020
- ```
1021
-
1022
- Generated types use:
1023
- - `Generated<T>` for auto-increment columns (optional on insert)
1024
- - `ColumnType<Select, Insert, Update>` for different operation types
1025
- - `Timestamp` for timestamptz columns
1026
-
1027
- ## Common Pitfalls to Avoid
1028
-
1029
- ### 1. Don't Resort to `sql`` When Kysely Has a Method
1030
-
1031
- ```typescript
1032
- // WRONG
1033
- .select(sql`count(*)`.as("count"))
1034
-
1035
- // RIGHT
1036
- .select((eb) => eb.fn.countAll().as("count"))
1037
-
1038
- // WRONG - raw SQL for FILTER (WHERE ...) on aggregates
1039
- .having(sql<number>`count(*) filter (where status != 'signed')`, "=", 0)
1040
-
1041
- // RIGHT - .filterWhere() works on all aggregate function builders
1042
- .having(
1043
- (eb) => eb.fn.countAll().filterWhere("status", "!=", "signed"),
1044
- "=",
1045
- 0
1046
- )
1047
- ```
1048
-
1049
- ### 2. Don't Forget .execute()
1050
-
1051
- Queries are lazy - they won't run without calling an execute method:
1052
-
1053
- ```typescript
1054
- // This does nothing!
1055
- db.selectFrom("user").selectAll();
1056
-
1057
- // This runs the query
1058
- await db.selectFrom("user").selectAll().execute();
1059
- ```
1060
-
1061
- ### 3. Use whereRef for Column-to-Column Comparisons
1062
-
1063
- ```typescript
1064
- // WRONG - Compares to string literal "other.column"
1065
- .where("table.column", "=", "other.column")
1066
-
1067
- // RIGHT - Compares to actual column value
1068
- .whereRef("table.column", "=", "other.column")
1069
- ```
1070
-
1071
- ### 4. Type Your Function Returns
1072
-
1073
- ```typescript
1074
- // Better type inference
1075
- eb.fn<string>("concat", [...])
1076
- eb.fn<number>("length", [...])
1077
- ```
1078
-
1079
- ### 5. PostgreSQL Does NOT Auto-Index Foreign Keys
1080
-
1081
- Always create indexes on foreign key columns:
1082
-
1083
- ```typescript
1084
- await db.schema.createIndex("idx_order_user_id").on("order").column("user_id").execute();
1085
- ```
1086
-
1087
- ### 6. Always Type `sql` Template Literals
1088
-
1089
- When using `sql` template literals, the inferred type is `unknown` since Kysely can't know what the SQL expression resolves to. Always provide an explicit type:
1090
-
1091
- ```typescript
1092
- // WRONG - Returns unknown type
1093
- eb.fn.coalesce("some_json_col", sql`'{}'::jsonb`)
1094
-
1095
- // RIGHT - Explicit type annotation
1096
- eb.fn.coalesce("some_json_col", sql<Record<string, unknown>>`'{}'::jsonb`)
1097
-
1098
- // For complex types (e.g., JSON column from a CTE), use typeof with eb.ref
1099
- // This ensures the fallback type matches the column type exactly
1100
- eb.fn
1101
- .coalesce(
1102
- eb.ref("jobs_agg.jobs"),
1103
- sql<typeof eb.ref<"jobs_agg.jobs">>`'[]'::json`
1104
- )
1105
- .as("jobs")
1106
- ```
1107
-
1108
- **Key rule**: Every `sql` template literal should have a type parameter: `sql<TYPE>`. This ensures proper type inference throughout your query chain.
1109
-
1110
- ### 7. DATE Columns Cause Timezone Issues
1111
-
1112
- By default, the `pg` driver converts DATE columns to JavaScript `Date` objects. This causes timezone problems:
1113
-
1114
- ```
1115
- Database: 2025-01-01 (just a date, no time)
1116
- JS Date: 2025-01-01T00:00:00.000Z (interpreted as UTC midnight)
1117
- User in NYC sees: Dec 31, 2024 (5 hours behind UTC)
1118
- ```
1119
-
1120
- **Solution: Parse DATE as string and let the frontend handle formatting**
1121
-
1122
- Step 1: Configure `pg` to return DATE as string:
1123
-
1124
- ```typescript
1125
- import pg from "pg";
1126
-
1127
- // Tell pg to return DATE columns as strings instead of Date objects
1128
- const DATE_OID = 1082;
1129
- pg.types.setTypeParser(DATE_OID, (val: string) => val);
1130
- ```
1131
-
1132
- Step 2: Update `kysely-codegen` to generate matching types:
1133
-
1134
- ```bash
1135
- npx kysely-codegen \
1136
- --url="$DATABASE_URL" \
1137
- --out-file=server/db/db.d.ts \
1138
- --dialect=postgres \
1139
- --date-parser=string
1140
- ```
1141
-
1142
- Now DATE columns return strings like `"2025-01-01"` and the frontend can parse/format respecting the user's timezone.
1143
-
1144
- **Note**: This applies to DATE columns only. TIMESTAMPTZ columns already handle timezones correctly by storing UTC and converting on read.
1145
-
1146
- ### 8. Don't Use the `between` String Operator — It Emits Invalid SQL
1147
-
1148
- The `"between"` operator looks like it should work but compiles to a tuple, which is a PostgreSQL syntax error:
1149
-
1150
- ```typescript
1151
- // WRONG - compiles to: "age" between ($1, $2) -> Postgres syntax error
1152
- .where("age", "between", [18, 65])
1153
-
1154
- // RIGHT - use the expression-builder helpers
1155
- .where((eb) => eb.between("age", 18, 65)) // "age" between $1 and $2
1156
- .where((eb) => eb.betweenSymmetric("age", 65, 18)) // swaps bounds if needed
1157
- ```
1158
-
1159
- ## PostgreSQL Helpers Summary
1160
-
1161
- All helpers from `kysely/helpers/postgres`:
1162
-
1163
- ```typescript
1164
- import {
1165
- jsonArrayFrom, // One-to-many relations (subquery → array)
1166
- jsonObjectFrom, // Many-to-one relations (subquery → object | null)
1167
- jsonBuildObject, // Build JSON object from expressions
1168
- mergeAction, // Get action performed in MERGE query (PostgreSQL 15+)
1169
- } from "kysely/helpers/postgres";
1170
- ```
1171
-
1172
- **Note**: `jsonAgg` is NOT imported - use `eb.fn.jsonAgg()` instead.
1173
-
1174
- ### mergeAction (PostgreSQL 15+)
1175
-
1176
- For MERGE queries, get which action was performed:
1177
-
1178
- ```typescript
1179
- import { mergeAction } from "kysely/helpers/postgres";
1180
-
1181
- const result = await db
1182
- .mergeInto("person")
1183
- .using("person_updates", "person.id", "person_updates.id")
1184
- .whenMatched()
1185
- .thenUpdateSet({ name: eb.ref("person_updates.name") })
1186
- .whenNotMatched()
1187
- .thenInsertValues({ id: eb.ref("person_updates.id"), name: eb.ref("person_updates.name") })
1188
- .returning([mergeAction().as("action"), "id"])
1189
- .execute();
1190
-
1191
- // result[0].action is 'INSERT' | 'UPDATE' | 'DELETE'
1192
- ```
1193
-
1194
- ## Extending Kysely
1195
-
1196
- ### Custom Helper Functions
1197
-
1198
- Most extensions use the `sql` template tag with `RawBuilder<T>`:
1199
-
1200
- ```typescript
1201
- import { sql, RawBuilder } from "kysely";
1202
-
1203
- // Create a typed helper function
1204
- function json<T>(value: T): RawBuilder<T> {
1205
- return sql`CAST(${JSON.stringify(value)} AS JSONB)`;
1206
- }
1207
-
1208
- // Use in queries
1209
- .select((eb) => [
1210
- json({ name: "value" }).as("data"),
1211
- ])
1212
- ```
1213
-
1214
- ### Custom Expression Classes
1215
-
1216
- For reusable expressions, implement the `Expression<T>` interface:
1217
-
1218
- ```typescript
1219
- import { Expression, OperationNode, sql } from "kysely";
1220
-
1221
- class JsonValue<T> implements Expression<T> {
1222
- readonly #value: T;
1223
-
1224
- constructor(value: T) {
1225
- this.#value = value;
1226
- }
1227
-
1228
- get expressionType(): T | undefined {
1229
- return undefined;
1230
- }
1231
-
1232
- toOperationNode(): OperationNode {
1233
- return sql`CAST(${JSON.stringify(this.#value)} AS JSONB)`.toOperationNode();
1234
- }
1235
- }
1236
- ```
1237
-
1238
- **Note**: Module augmentation and inheritance-based extension are not recommended.
1239
-
1240
- ## Handling "Excessively Deep Types" Error
1241
-
1242
- ### The Problem
1243
-
1244
- Complex queries with many CTEs can overwhelm TypeScript's type instantiation limits:
1245
-
1246
- ```
1247
- Type instantiation is excessively deep and possibly infinite
1248
- ```
1249
-
1250
- This commonly occurs with 12+ `with` clauses, as Kysely's nested helper types accumulate.
1251
-
1252
- ### The Solution: `$assertType`
1253
-
1254
- Use `$assertType` to simplify the type chain at intermediate points:
1255
-
1256
- ```typescript
1257
- const result = await db
1258
- .with("cte1", (qb) =>
1259
- qb.selectFrom("user")
1260
- .select(["id", "email"])
1261
- .$assertType<{ id: number; email: string }>() // Simplify type here
1262
- )
1263
- .with("cte2", (qb) =>
1264
- qb.selectFrom("cte1")
1265
- .select("email")
1266
- .$assertType<{ email: string }>()
1267
- )
1268
- // ... more CTEs
1269
- .selectFrom("cteN")
1270
- .selectAll()
1271
- .execute();
1272
- ```
1273
-
1274
- **Key points**:
1275
- - The asserted type must structurally match the actual type (full type safety preserved)
1276
- - Apply to several intermediate `with` clauses in large queries
1277
- - TypeScript cannot automatically simplify these types - explicit assertion is required
1278
-
1279
56
  ## Contributing Back
1280
57
 
1281
58
  This skill grows by capturing what it missed. If you just worked through something in this domain that this skill did not cover — an error you had to figure out, a behavior that contradicts what is documented above, a workflow knot — ask the user: **"Want me to contribute this back to the kysely-postgres skill?"**