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