@gallopsystems/agent-skills 1.14.0 → 1.16.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.
- package/package.json +1 -1
- package/plugins/copier-template/skills/copier-template/SKILL.md +3 -3
- package/plugins/copier-template/skills/copier-template/applying-updates.md +156 -16
- package/plugins/kysely-postgres/skills/kysely-postgres/SKILL.md +16 -1262
- package/plugins/kysely-postgres/skills/kysely-postgres/references/common-pitfalls.md +132 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/deep-types.md +39 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/expression-builder.md +132 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/full-text-and-grouping.md +26 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/helpers-and-extending.md +81 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/json-jsonb-arrays.md +374 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/migrations-and-codegen.md +162 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/query-patterns.md +235 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/seeding-pattern.md +143 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/set-lateral-locking.md +46 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/window-functions.md +37 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
## Query Patterns
|
|
2
|
+
|
|
3
|
+
### Basic SELECT
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
// Select all columns
|
|
7
|
+
const users = await db.selectFrom("user").selectAll().execute();
|
|
8
|
+
|
|
9
|
+
// Select specific columns with aliases
|
|
10
|
+
const users = await db
|
|
11
|
+
.selectFrom("user")
|
|
12
|
+
.select(["id", "email", "first_name as firstName"])
|
|
13
|
+
.execute();
|
|
14
|
+
|
|
15
|
+
// Single row (returns T | undefined)
|
|
16
|
+
const user = await db.selectFrom("user").selectAll()
|
|
17
|
+
.where("id", "=", userId).executeTakeFirst();
|
|
18
|
+
|
|
19
|
+
// Single row that must exist (throws if not found)
|
|
20
|
+
const user = await db.selectFrom("user").selectAll()
|
|
21
|
+
.where("id", "=", userId).executeTakeFirstOrThrow();
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### WHERE Clauses
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
// Equality, comparison, IN, LIKE
|
|
28
|
+
.where("status", "=", "active")
|
|
29
|
+
.where("price", ">", 100)
|
|
30
|
+
.where("role", "in", ["admin", "manager"])
|
|
31
|
+
.where("name", "like", "%search%")
|
|
32
|
+
.where("deleted_at", "is", null)
|
|
33
|
+
|
|
34
|
+
// BETWEEN - use eb.between(), NOT the "between" operator (see Pitfall #8)
|
|
35
|
+
.where((eb) => eb.between("age", 18, 65))
|
|
36
|
+
|
|
37
|
+
// Multiple conditions (chained = AND)
|
|
38
|
+
.where("is_active", "=", true)
|
|
39
|
+
.where("role", "=", "admin")
|
|
40
|
+
|
|
41
|
+
// OR conditions
|
|
42
|
+
.where((eb) => eb.or([
|
|
43
|
+
eb("role", "=", "admin"),
|
|
44
|
+
eb("role", "=", "manager"),
|
|
45
|
+
]))
|
|
46
|
+
|
|
47
|
+
// Complex AND/OR
|
|
48
|
+
.where((eb) => eb.and([
|
|
49
|
+
eb("is_active", "=", true),
|
|
50
|
+
eb.or([
|
|
51
|
+
eb("price", "<", 50),
|
|
52
|
+
eb("stock", ">", 100),
|
|
53
|
+
]),
|
|
54
|
+
]))
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### JOINs
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
// Inner join
|
|
61
|
+
.innerJoin("order", "order.user_id", "user.id")
|
|
62
|
+
|
|
63
|
+
// Left join
|
|
64
|
+
.leftJoin("category", "category.id", "product.category_id")
|
|
65
|
+
|
|
66
|
+
// Self-join with alias
|
|
67
|
+
.selectFrom("category as c")
|
|
68
|
+
.leftJoin("category as parent", "parent.id", "c.parent_id")
|
|
69
|
+
|
|
70
|
+
// Multiple joins
|
|
71
|
+
.innerJoin("order", "order.id", "order_item.order_id")
|
|
72
|
+
.innerJoin("product", "product.id", "order_item.product_id")
|
|
73
|
+
.innerJoin("user", "user.id", "order.user_id")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Complex JOINs (Callback Format)
|
|
77
|
+
|
|
78
|
+
Use the callback format when you need:
|
|
79
|
+
- Multiple join conditions (composite keys)
|
|
80
|
+
- Mixed column-to-column and column-to-literal comparisons
|
|
81
|
+
- OR conditions within joins
|
|
82
|
+
- Subquery joins (derived tables)
|
|
83
|
+
|
|
84
|
+
**Join Builder Methods:**
|
|
85
|
+
- `onRef(col1, op, col2)` - Column-to-column comparison
|
|
86
|
+
- `on(col, op, value)` - Column-to-literal comparison
|
|
87
|
+
- `on((eb) => ...)` - Complex expressions with OR logic
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
// Multi-condition join (composite key + filter)
|
|
91
|
+
.leftJoin("invoice as i", (join) =>
|
|
92
|
+
join
|
|
93
|
+
.onRef("sp.service_provider_id", "=", "i.service_provider_id")
|
|
94
|
+
.onRef("sp.year", "=", "i.year")
|
|
95
|
+
.onRef("sp.month", "=", "i.month")
|
|
96
|
+
.on("i.status", "!=", "invalidated")
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
// Join with OR conditions
|
|
100
|
+
.leftJoin("order as o", (join) =>
|
|
101
|
+
join
|
|
102
|
+
.onRef("o.user_id", "=", "u.id")
|
|
103
|
+
.on((eb) =>
|
|
104
|
+
eb.or([
|
|
105
|
+
eb("o.status", "=", "completed"),
|
|
106
|
+
eb("o.status", "=", "shipped"),
|
|
107
|
+
])
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
// Subquery join (derived table) - two callbacks
|
|
112
|
+
.leftJoin(
|
|
113
|
+
(eb) =>
|
|
114
|
+
eb
|
|
115
|
+
.selectFrom("order")
|
|
116
|
+
.select((eb) => [
|
|
117
|
+
"user_id",
|
|
118
|
+
eb.fn.count("id").as("order_count"),
|
|
119
|
+
eb.fn.max("created_at").as("last_order_at"),
|
|
120
|
+
])
|
|
121
|
+
.groupBy("user_id")
|
|
122
|
+
.as("order_stats"), // MUST have alias!
|
|
123
|
+
(join) => join.onRef("order_stats.user_id", "=", "u.id")
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
// Cross join (always-true condition) - for joining aggregated CTEs
|
|
127
|
+
.leftJoin("summary_cte", (join) =>
|
|
128
|
+
join.on(sql`true`, "=", sql`true`)
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Aggregations
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
.select((eb) => [
|
|
136
|
+
"status",
|
|
137
|
+
eb.fn.count("id").as("count"),
|
|
138
|
+
eb.fn.sum("total_amount").as("totalAmount"),
|
|
139
|
+
eb.fn.avg("total_amount").as("avgAmount"),
|
|
140
|
+
])
|
|
141
|
+
.groupBy("status")
|
|
142
|
+
.having((eb) => eb.fn.count("id"), ">", 5)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
#### FILTER (WHERE ...) on Aggregates
|
|
146
|
+
|
|
147
|
+
PostgreSQL's `FILTER (WHERE ...)` clause is available on **all** aggregate function builders via `.filterWhere()`:
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
.select((eb) => [
|
|
151
|
+
eb.fn.count("id").filterWhere("status", "=", "active").as("active_count"),
|
|
152
|
+
eb.fn.countAll().filterWhere("role", "!=", "banned").as("non_banned"),
|
|
153
|
+
eb.fn.sum("amount").filterWhere("type", "=", "credit").as("total_credits"),
|
|
154
|
+
])
|
|
155
|
+
|
|
156
|
+
// Also works as the first argument to .having()
|
|
157
|
+
.having(
|
|
158
|
+
(eb) => eb.fn.countAll().filterWhere("status", "!=", "signed"),
|
|
159
|
+
"=",
|
|
160
|
+
0
|
|
161
|
+
)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### ORDER BY
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
// Simple ordering
|
|
168
|
+
.orderBy("created_at", "desc")
|
|
169
|
+
.orderBy("name", "asc")
|
|
170
|
+
|
|
171
|
+
// NULLS FIRST / NULLS LAST - use order builder callback
|
|
172
|
+
.orderBy("category_id", (ob) => ob.asc().nullsLast())
|
|
173
|
+
.orderBy("priority", (ob) => ob.desc().nullsFirst())
|
|
174
|
+
|
|
175
|
+
// Multiple columns - chain orderBy calls (array syntax is deprecated)
|
|
176
|
+
.orderBy("category_id", "asc")
|
|
177
|
+
.orderBy("price", "desc")
|
|
178
|
+
.orderBy("name", "asc")
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### CTEs (Common Table Expressions)
|
|
182
|
+
|
|
183
|
+
Use CTEs for complex queries with multiple aggregation levels:
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
const result = await db
|
|
187
|
+
.with("order_totals", (db) =>
|
|
188
|
+
db.selectFrom("order")
|
|
189
|
+
.innerJoin("user", "user.id", "order.user_id")
|
|
190
|
+
.select((eb) => [
|
|
191
|
+
"user.id as userId",
|
|
192
|
+
"user.email",
|
|
193
|
+
eb.fn.sum("order.total_amount").as("totalSpent"),
|
|
194
|
+
eb.fn.count("order.id").as("orderCount"),
|
|
195
|
+
])
|
|
196
|
+
.groupBy(["user.id", "user.email"])
|
|
197
|
+
)
|
|
198
|
+
.selectFrom("order_totals")
|
|
199
|
+
.selectAll()
|
|
200
|
+
.orderBy("totalSpent", "desc")
|
|
201
|
+
.execute();
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### JSON Aggregation (PostgreSQL)
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
import { jsonBuildObject } from "kysely/helpers/postgres";
|
|
208
|
+
// Note: jsonAgg is accessed via eb.fn.jsonAgg(), not imported
|
|
209
|
+
|
|
210
|
+
.with("tasks", (db) =>
|
|
211
|
+
db.selectFrom("task")
|
|
212
|
+
.leftJoin("user", "user.id", "task.assignee_id")
|
|
213
|
+
.select((eb) => [
|
|
214
|
+
"task.job_id",
|
|
215
|
+
eb.fn.jsonAgg(
|
|
216
|
+
jsonBuildObject({
|
|
217
|
+
id: eb.ref("task.id"),
|
|
218
|
+
status: eb.ref("task.status"),
|
|
219
|
+
assignee: jsonBuildObject({
|
|
220
|
+
id: eb.ref("user.id"),
|
|
221
|
+
name: eb.fn<string>("concat", [
|
|
222
|
+
eb.ref("user.first_name"),
|
|
223
|
+
eb.cast(eb.val(" "), "text"),
|
|
224
|
+
eb.ref("user.last_name"),
|
|
225
|
+
]),
|
|
226
|
+
}),
|
|
227
|
+
})
|
|
228
|
+
)
|
|
229
|
+
.filterWhere("task.id", "is not", null) // Filter nulls from left join
|
|
230
|
+
.as("tasks"),
|
|
231
|
+
])
|
|
232
|
+
.groupBy("task.job_id")
|
|
233
|
+
)
|
|
234
|
+
```
|
|
235
|
+
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Idempotent seeding pattern
|
|
2
|
+
|
|
3
|
+
How to seed a preview/dev database so it stays useful as the app grows — without
|
|
4
|
+
the seeds drifting out of sync with the schema. This is a *convention*, not a
|
|
5
|
+
Kysely feature; it layers on top of `kysely seed make` / `kysely seed run`.
|
|
6
|
+
|
|
7
|
+
Not every project has (or needs) a seed system. Reach for this when there's a
|
|
8
|
+
preview or dev box that a human logs into to *see* the app — a seedless box shows
|
|
9
|
+
empty screens, so every new feature has to be seeded to be visible there. A
|
|
10
|
+
library or a pure-API service with no such box doesn't need any of this.
|
|
11
|
+
|
|
12
|
+
## What seeds are for (and what they are not)
|
|
13
|
+
|
|
14
|
+
- **Migrations** define the schema. **Tests** assert behavior and roll back.
|
|
15
|
+
**Seeds** populate a *live* preview/dev DB with the minimum data needed to log
|
|
16
|
+
in and exercise the features — and they *commit*.
|
|
17
|
+
- A seed's job is to make a feature **visible and reachable** in the preview, not
|
|
18
|
+
to test it. Seed one row per state worth seeing, not exhaustive permutations.
|
|
19
|
+
|
|
20
|
+
## The core idea: factories shared by tests AND seeds
|
|
21
|
+
|
|
22
|
+
Put the row-level builders in one framework-agnostic module (e.g.
|
|
23
|
+
`db/factories.ts`) that both the test suite and the seeds import. Each builder
|
|
24
|
+
inserts a single row with sensible defaults plus the FK ids the caller threads
|
|
25
|
+
in.
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// Accepts either a live connection (seeds) or a test transaction (tests).
|
|
29
|
+
export type DbLike = Kysely<DB> | Transaction<DB>;
|
|
30
|
+
|
|
31
|
+
export function createFactories(db: DbLike) {
|
|
32
|
+
return {
|
|
33
|
+
async user(data: Partial<{ email: string; name: string }> = {}) {
|
|
34
|
+
const n = nextSeq();
|
|
35
|
+
return db.insertInto("users")
|
|
36
|
+
.values({ email: data.email ?? `user${n}@example.com`, name: data.name ?? "User" })
|
|
37
|
+
.returningAll().executeTakeFirstOrThrow();
|
|
38
|
+
},
|
|
39
|
+
// ...one builder per table you seed
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- **Tests** call `createFactories(trx)` with a per-test transaction (rolled back).
|
|
45
|
+
- **Seeds** call `createFactories(db)` with the live connection (committed).
|
|
46
|
+
- A scene that reads well in a seed reads well in a test, and vice versa — one
|
|
47
|
+
source of truth for "a valid row of X".
|
|
48
|
+
|
|
49
|
+
**DO** keep this module free of any test-framework import. Seeds load it at
|
|
50
|
+
runtime under the bare `seed run` context (no test runner, no app DI / framework
|
|
51
|
+
auto-imports), so a stray `import { test } from "vitest"` breaks `seed run`.
|
|
52
|
+
|
|
53
|
+
## Uniqueness: a monotonic counter, not randomness
|
|
54
|
+
|
|
55
|
+
Drive unique fields off a process-global counter, not random suffixes.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
let seq = 0;
|
|
59
|
+
const nextSeq = () => ++seq; // user1@…, user2@…, never collides
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- **DO** use `nextSeq()` for every "just needs to be unique" value.
|
|
63
|
+
- **DON'T** use `Math.random()` / timestamps for uniqueness — they birthday-collide
|
|
64
|
+
eventually and make failures non-reproducible.
|
|
65
|
+
|
|
66
|
+
## Idempotent + order-independent — the hard contract
|
|
67
|
+
|
|
68
|
+
Seed runners (e.g. kysely-ctl) execute every file in directory-read order with
|
|
69
|
+
**no ledger** and re-run on every preview deploy. So:
|
|
70
|
+
|
|
71
|
+
- **Order-independent** — a seed must NOT depend on another seed having run
|
|
72
|
+
first. Each file builds the world it needs from factories.
|
|
73
|
+
- **Idempotent** — running the full set twice must not throw and must not
|
|
74
|
+
duplicate. For most rows, `nextSeq()` already guarantees no collision. For a
|
|
75
|
+
row that must be **stable and addressable across re-seeds** (the canonical
|
|
76
|
+
example: the anchor login user the preview logs in as), upsert on a natural
|
|
77
|
+
key instead:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
await db.insertInto("users").values(anchor)
|
|
81
|
+
.onConflict((oc) => oc.column("email").doUpdateSet(anchor))
|
|
82
|
+
.execute();
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- **DON'T** hardcode a unique literal in a plain `insertInto` — the second run
|
|
86
|
+
throws on the unique constraint.
|
|
87
|
+
- **DON'T** reach across files for an id; if two scenes truly share an entity,
|
|
88
|
+
give each its own copy or upsert a shared anchor both can look up by key.
|
|
89
|
+
|
|
90
|
+
## Build scenes directly in their target state
|
|
91
|
+
|
|
92
|
+
When an entity has a lifecycle (draft → sent → accepted), create each seed row
|
|
93
|
+
*directly* in the state you want to see by writing the same stored columns the
|
|
94
|
+
real transition writes (status + last-action + timestamps), rather than inserting
|
|
95
|
+
a draft and calling the app's transition code.
|
|
96
|
+
|
|
97
|
+
- **DO** set the columns directly. Seeds run in a bare context and should compose
|
|
98
|
+
only factories + plain inserts.
|
|
99
|
+
- **DON'T** import the app's service/transition utilities into a seed — they
|
|
100
|
+
often rely on framework auto-imports or request context that doesn't exist
|
|
101
|
+
under `seed run`. (Guard the "directly-built state is actually reachable/
|
|
102
|
+
consistent" claim with a test instead.)
|
|
103
|
+
|
|
104
|
+
## The validity harness — what keeps seeds from rotting
|
|
105
|
+
|
|
106
|
+
Typecheck + codegen catch *shape* drift (a seed referencing a renamed column).
|
|
107
|
+
They do **not** catch runtime breakage. Add one test — the validity harness —
|
|
108
|
+
that, against a freshly-migrated schema:
|
|
109
|
+
|
|
110
|
+
1. Runs **every** seed file (auto-discovered, so a new broken seed is caught for
|
|
111
|
+
free), inside a rolled-back transaction so the shared test DB stays clean.
|
|
112
|
+
2. Runs the full set **twice** and asserts no throw — proving idempotency.
|
|
113
|
+
|
|
114
|
+
This catches the two failure modes typecheck can't:
|
|
115
|
+
|
|
116
|
+
- **Constraint drift** — a migration adds a NOT NULL column with no default, a
|
|
117
|
+
new CHECK, or a new UNIQUE, and every seed inserting that table now throws at
|
|
118
|
+
runtime while still typechecking.
|
|
119
|
+
- **Non-idempotency** — a hardcoded unique value or an unhandled conflict that
|
|
120
|
+
only surfaces on the second run.
|
|
121
|
+
|
|
122
|
+
**DO** discover seed files dynamically (glob / import-all) so the harness covers
|
|
123
|
+
new seeds automatically. **DON'T** maintain a hand-listed array of seeds to test
|
|
124
|
+
— it silently misses the next one added.
|
|
125
|
+
|
|
126
|
+
## Preview login, briefly
|
|
127
|
+
|
|
128
|
+
A preview box often serves on a dynamic URL that can't be a fixed OAuth redirect,
|
|
129
|
+
so it logs in via a dev-only backdoor as the seeded anchor user. Keep the anchor
|
|
130
|
+
user definition in **one** module that both the seed and the login route import,
|
|
131
|
+
and gate the backdoor on **both** a compile-time dev check (dead in prod builds)
|
|
132
|
+
**and** a runtime flag — so it can't ship enabled. The anchor user is the
|
|
133
|
+
upsert-on-natural-key row from the idempotency section.
|
|
134
|
+
|
|
135
|
+
## Checklist
|
|
136
|
+
|
|
137
|
+
- [ ] Factories module is test-framework-free and shared by tests + seeds.
|
|
138
|
+
- [ ] Unique values come from a counter, not randomness.
|
|
139
|
+
- [ ] Each seed is self-sufficient (no cross-file ordering dependency).
|
|
140
|
+
- [ ] Stable/addressable rows upsert on a natural key; everything else rides the counter.
|
|
141
|
+
- [ ] Lifecycle rows are built directly in their target state, no app-util imports.
|
|
142
|
+
- [ ] A validity harness runs every seed twice against the latest migration.
|
|
143
|
+
- [ ] The preview anchor user lives in one module, gated dev-only + runtime-flagged.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
## Set Operations (UNION / INTERSECT / EXCEPT)
|
|
2
|
+
|
|
3
|
+
Combine two compatible queries. Plain forms dedupe; `*All` forms keep duplicates (and are cheaper). See [set-operations.ts](references/set-operations.ts).
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
db.selectFrom("order").select("user_id")
|
|
7
|
+
.except(db.selectFrom("review").select("user_id")) // orders, never reviewed
|
|
8
|
+
.execute();
|
|
9
|
+
// .union/.unionAll, .intersect/.intersectAll, .except/.exceptAll
|
|
10
|
+
// Both branches must select matching columns/names — align with `as` aliases.
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## LATERAL Joins (PostgreSQL)
|
|
14
|
+
|
|
15
|
+
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).
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
// Each user's 3 most recent orders
|
|
19
|
+
db.selectFrom("user as u")
|
|
20
|
+
.innerJoinLateral(
|
|
21
|
+
(eb) => eb.selectFrom("order as o")
|
|
22
|
+
.select(["o.id", "o.total_amount", "o.created_at"])
|
|
23
|
+
.whereRef("o.user_id", "=", "u.id")
|
|
24
|
+
.orderBy("o.created_at", "desc").limit(3).as("recent"),
|
|
25
|
+
(join) => join.onTrue()
|
|
26
|
+
)
|
|
27
|
+
.select(["u.email", "recent.id", "recent.total_amount"])
|
|
28
|
+
.execute();
|
|
29
|
+
// leftJoinLateral / crossJoinLateral also exist.
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Row Locking (FOR UPDATE / SKIP LOCKED)
|
|
33
|
+
|
|
34
|
+
Pessimistic locks for read-modify-write and job queues (run inside a transaction). See [locking.ts](references/locking.ts).
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
// Job-queue worker: grab the next pending jobs, skipping rows other workers hold
|
|
38
|
+
db.selectFrom("job").selectAll()
|
|
39
|
+
.where("status", "=", "pending")
|
|
40
|
+
.orderBy("created_at").limit(10)
|
|
41
|
+
.forUpdate().skipLocked()
|
|
42
|
+
.execute();
|
|
43
|
+
// Lock strength: forKeyShare < forShare < forNoKeyUpdate < forUpdate
|
|
44
|
+
// Wait behavior: .skipLocked() (skip) or .noWait() (error instead of blocking)
|
|
45
|
+
```
|
|
46
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
## Window Functions
|
|
2
|
+
|
|
3
|
+
Two builders cover almost everything. See [window-functions.ts](references/window-functions.ts) for the full set.
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
// Named window functions (no dedicated helper): eb.fn.agg<T>("NAME", [args])
|
|
7
|
+
.select((eb) => [
|
|
8
|
+
eb.fn.agg<number>("ROW_NUMBER")
|
|
9
|
+
.over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
|
|
10
|
+
.as("rank"),
|
|
11
|
+
// LAG/LEAD args go in the array; wrap literals in sql.lit()
|
|
12
|
+
eb.fn.agg<number | null>("LAG", ["total_amount", sql.lit(1)])
|
|
13
|
+
.over((ob) => ob.partitionBy("user_id").orderBy("created_at"))
|
|
14
|
+
.as("prev_amount"),
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
// Windowed aggregates: .over() on sum/count/avg/min/max
|
|
18
|
+
.select((eb) => [
|
|
19
|
+
eb.fn.sum<number>("total_amount").over((ob) => ob.orderBy("created_at")).as("running_total"),
|
|
20
|
+
eb.fn.avg<number>("price").over().as("grand_avg"), // empty OVER ()
|
|
21
|
+
// .filterWhere() and .distinct() compose with .over()
|
|
22
|
+
eb.fn.countAll<number>().filterWhere("status", "=", "completed")
|
|
23
|
+
.over((ob) => ob.partitionBy("user_id")).as("completed_for_user"),
|
|
24
|
+
])
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**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.
|
|
28
|
+
|
|
29
|
+
**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()`:
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
// Only the function name + frame keywords are raw; columns stay validated.
|
|
33
|
+
sql<number>`avg(${eb.ref("total_amount")}) over (
|
|
34
|
+
order by ${eb.ref("created_at")} rows between 2 preceding and current row
|
|
35
|
+
)`.as("moving_avg_3")
|
|
36
|
+
```
|
|
37
|
+
|