@gallopsystems/agent-skills 1.6.1 → 1.8.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/kysely-postgres/skills/kysely-postgres/SKILL.md +134 -5
- package/plugins/kysely-postgres/skills/kysely-postgres/references/aggregations.ts +44 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/ctes.ts +44 -1
- package/plugins/kysely-postgres/skills/kysely-postgres/references/expressions.ts +26 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/full-text-search.ts +115 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/joins.ts +52 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/locking.ts +94 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/select-where.ts +59 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/set-operations.ts +97 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/window-functions.ts +294 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/SKILL.md +2 -2
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/nitro-tasks.md +1 -1
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/validation.md +23 -6
- package/plugins/tailwind-v4/.claude-plugin/plugin.json +8 -0
- package/plugins/tailwind-v4/skills/tailwind-v4/SKILL.md +133 -0
- package/plugins/tailwind-v4/skills/tailwind-v4/gotchas.md +84 -0
- package/plugins/volt-primevue/skills/volt-primevue/gotchas.md +25 -0
package/package.json
CHANGED
|
@@ -19,15 +19,19 @@ Use this skill when:
|
|
|
19
19
|
|
|
20
20
|
For detailed examples, see these topic-focused reference files:
|
|
21
21
|
|
|
22
|
-
- [select-where.ts](references/select-where.ts) - Basic SELECT patterns, WHERE clauses, AND/OR
|
|
23
|
-
- [joins.ts](references/joins.ts) - Simple joins, callback joins, subquery joins, cross joins
|
|
24
|
-
- [aggregations.ts](references/aggregations.ts) - COUNT, SUM, AVG, GROUP BY, HAVING
|
|
22
|
+
- [select-where.ts](references/select-where.ts) - Basic SELECT patterns, WHERE clauses, AND/OR, BETWEEN, ANY
|
|
23
|
+
- [joins.ts](references/joins.ts) - Simple joins, callback joins, subquery joins, cross joins, lateral joins
|
|
24
|
+
- [aggregations.ts](references/aggregations.ts) - COUNT, SUM, AVG, GROUP BY, HAVING, ROLLUP/CUBE/GROUPING SETS
|
|
25
|
+
- [window-functions.ts](references/window-functions.ts) - ROW_NUMBER/RANK, LAG/LEAD, windowed aggregates, frames
|
|
25
26
|
- [orderby-pagination.ts](references/orderby-pagination.ts) - ORDER BY, NULLS handling, DISTINCT, pagination
|
|
26
|
-
- [ctes.ts](references/ctes.ts) - Common Table Expressions, multiple CTEs, recursive CTEs
|
|
27
|
+
- [ctes.ts](references/ctes.ts) - Common Table Expressions, multiple CTEs, recursive CTEs, MATERIALIZED
|
|
28
|
+
- [set-operations.ts](references/set-operations.ts) - UNION, INTERSECT, EXCEPT (and *All variants)
|
|
27
29
|
- [json-arrays.ts](references/json-arrays.ts) - JSONB handling, array columns, jsonBuildObject, jsonAgg
|
|
28
30
|
- [relations.ts](references/relations.ts) - jsonArrayFrom, jsonObjectFrom for nested data
|
|
31
|
+
- [full-text-search.ts](references/full-text-search.ts) - tsvector/tsquery matching with @@, ranking
|
|
32
|
+
- [locking.ts](references/locking.ts) - FOR UPDATE/SHARE, SKIP LOCKED, NOWAIT, job-queue pattern
|
|
29
33
|
- [mutations.ts](references/mutations.ts) - INSERT, UPDATE, DELETE, UPSERT, INSERT FROM SELECT
|
|
30
|
-
- [expressions.ts](references/expressions.ts) - CASE, $if, subqueries, eb.val/lit/not, standalone
|
|
34
|
+
- [expressions.ts](references/expressions.ts) - CASE, $if, subqueries, eb.val/lit/not, standalone eb, dynamic refs
|
|
31
35
|
|
|
32
36
|
## Core Principles
|
|
33
37
|
|
|
@@ -177,6 +181,9 @@ const user = await db.selectFrom("user").selectAll()
|
|
|
177
181
|
.where("name", "like", "%search%")
|
|
178
182
|
.where("deleted_at", "is", null)
|
|
179
183
|
|
|
184
|
+
// BETWEEN - use eb.between(), NOT the "between" operator (see Pitfall #8)
|
|
185
|
+
.where((eb) => eb.between("age", 18, 65))
|
|
186
|
+
|
|
180
187
|
// Multiple conditions (chained = AND)
|
|
181
188
|
.where("is_active", "=", true)
|
|
182
189
|
.where("role", "=", "admin")
|
|
@@ -750,6 +757,115 @@ await db
|
|
|
750
757
|
.executeTakeFirst();
|
|
751
758
|
```
|
|
752
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
|
+
|
|
753
869
|
## Migrations
|
|
754
870
|
|
|
755
871
|
### Configuration (kysely.config.ts)
|
|
@@ -980,6 +1096,19 @@ Now DATE columns return strings like `"2025-01-01"` and the frontend can parse/f
|
|
|
980
1096
|
|
|
981
1097
|
**Note**: This applies to DATE columns only. TIMESTAMPTZ columns already handle timezones correctly by storing UTC and converting on read.
|
|
982
1098
|
|
|
1099
|
+
### 8. Don't Use the `between` String Operator — It Emits Invalid SQL
|
|
1100
|
+
|
|
1101
|
+
The `"between"` operator looks like it should work but compiles to a tuple, which is a PostgreSQL syntax error:
|
|
1102
|
+
|
|
1103
|
+
```typescript
|
|
1104
|
+
// WRONG - compiles to: "age" between ($1, $2) -> Postgres syntax error
|
|
1105
|
+
.where("age", "between", [18, 65])
|
|
1106
|
+
|
|
1107
|
+
// RIGHT - use the expression-builder helpers
|
|
1108
|
+
.where((eb) => eb.between("age", 18, 65)) // "age" between $1 and $2
|
|
1109
|
+
.where((eb) => eb.betweenSymmetric("age", 65, 18)) // swaps bounds if needed
|
|
1110
|
+
```
|
|
1111
|
+
|
|
983
1112
|
## PostgreSQL Helpers Summary
|
|
984
1113
|
|
|
985
1114
|
All helpers from `kysely/helpers/postgres`:
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* COUNT, SUM, AVG, GROUP BY, HAVING
|
|
4
4
|
*/
|
|
5
5
|
import { db } from "./db";
|
|
6
|
+
import { sql } from "kysely";
|
|
6
7
|
|
|
7
8
|
// ============================================
|
|
8
9
|
// BASIC AGGREGATIONS
|
|
@@ -109,6 +110,44 @@ const groupsWithNoUnsigned = await db
|
|
|
109
110
|
)
|
|
110
111
|
.execute();
|
|
111
112
|
|
|
113
|
+
// ============================================
|
|
114
|
+
// ADVANCED GROUPING (ROLLUP / CUBE / GROUPING SETS)
|
|
115
|
+
// ============================================
|
|
116
|
+
|
|
117
|
+
// Kysely has no builder for these grouping constructs, so pass them to
|
|
118
|
+
// .groupBy() as a sql`` fragment. The SELECT list stays fully typed; only the
|
|
119
|
+
// grouping spec is raw. Rows with NULL in a grouped column are the subtotals.
|
|
120
|
+
|
|
121
|
+
// ROLLUP — hierarchical subtotals + grand total
|
|
122
|
+
// (type, region) -> (type) -> ()
|
|
123
|
+
const rollup = await db
|
|
124
|
+
.selectFrom("order")
|
|
125
|
+
.select((eb) => [
|
|
126
|
+
"status",
|
|
127
|
+
eb.fn.sum("total_amount").as("total"),
|
|
128
|
+
])
|
|
129
|
+
.groupBy(sql`rollup("status")`)
|
|
130
|
+
.execute();
|
|
131
|
+
// SQL: group by rollup("status")
|
|
132
|
+
|
|
133
|
+
// CUBE — all combinations of the grouped columns
|
|
134
|
+
const cube = await db
|
|
135
|
+
.selectFrom("order_item")
|
|
136
|
+
.select((eb) => [
|
|
137
|
+
"order_id",
|
|
138
|
+
"product_id",
|
|
139
|
+
eb.fn.sum("quantity").as("qty"),
|
|
140
|
+
])
|
|
141
|
+
.groupBy(sql`cube("order_id", "product_id")`)
|
|
142
|
+
.execute();
|
|
143
|
+
|
|
144
|
+
// GROUPING SETS — pick exactly which groupings to compute
|
|
145
|
+
const sets = await db
|
|
146
|
+
.selectFrom("order")
|
|
147
|
+
.select((eb) => ["status", "user_id", eb.fn.sum("total_amount").as("total")])
|
|
148
|
+
.groupBy(sql`grouping sets (("status"), ("user_id"), ())`)
|
|
149
|
+
.execute();
|
|
150
|
+
|
|
112
151
|
// ============================================
|
|
113
152
|
// AGGREGATE FUNCTIONS REFERENCE
|
|
114
153
|
// ============================================
|
|
@@ -164,4 +203,9 @@ const productStats = await db
|
|
|
164
203
|
- Replaces CASE WHEN ... inside aggregates for conditional aggregation
|
|
165
204
|
- Works as the first argument to .having() for filtered aggregate conditions
|
|
166
205
|
- Example: eb.fn.count("id").filterWhere("status", "=", "active").as("active_count")
|
|
206
|
+
|
|
207
|
+
6. ROLLUP / CUBE / GROUPING SETS (no builder):
|
|
208
|
+
- Pass to .groupBy() as a sql`` fragment: .groupBy(sql`rollup("a", "b")`)
|
|
209
|
+
- SELECT list stays typed; subtotal rows have NULL in the rolled-up columns
|
|
210
|
+
- Windowed aggregates (sum/count/... .over()) live in window-functions.ts
|
|
167
211
|
*/
|
|
@@ -132,6 +132,44 @@ const categoryHierarchy = await db
|
|
|
132
132
|
.selectAll()
|
|
133
133
|
.execute();
|
|
134
134
|
|
|
135
|
+
// ============================================
|
|
136
|
+
// MATERIALIZED / NOT MATERIALIZED (PostgreSQL)
|
|
137
|
+
// ============================================
|
|
138
|
+
|
|
139
|
+
// PostgreSQL can inline a CTE into the outer query (so the planner optimizes
|
|
140
|
+
// across the boundary) or materialize it once into a temp result. Force either
|
|
141
|
+
// with a name-builder callback as the first arg to .with():
|
|
142
|
+
// (cte) => cte("name").materialized() -> WITH "name" AS MATERIALIZED (...)
|
|
143
|
+
// (cte) => cte("name").notMaterialized() -> WITH "name" AS NOT MATERIALIZED (...)
|
|
144
|
+
|
|
145
|
+
// MATERIALIZED: compute the CTE once and reuse it (good when it's expensive and
|
|
146
|
+
// referenced multiple times, or to deliberately create an optimization fence).
|
|
147
|
+
const expensiveOnce = await db
|
|
148
|
+
.with(
|
|
149
|
+
(cte) => cte("active_products").materialized(),
|
|
150
|
+
(db) =>
|
|
151
|
+
db
|
|
152
|
+
.selectFrom("product")
|
|
153
|
+
.select(["id", "name", "price"])
|
|
154
|
+
.where("is_active", "=", true)
|
|
155
|
+
)
|
|
156
|
+
.selectFrom("active_products")
|
|
157
|
+
.selectAll()
|
|
158
|
+
.execute();
|
|
159
|
+
// SQL: with "active_products" as materialized (select ...) select * from ...
|
|
160
|
+
|
|
161
|
+
// NOT MATERIALIZED: let the planner inline it (often lets index conditions from
|
|
162
|
+
// the outer query push down into the CTE).
|
|
163
|
+
const inlined = await db
|
|
164
|
+
.with(
|
|
165
|
+
(cte) => cte("recent_orders").notMaterialized(),
|
|
166
|
+
(db) =>
|
|
167
|
+
db.selectFrom("order").select(["id", "user_id"]).where("status", "=", "pending")
|
|
168
|
+
)
|
|
169
|
+
.selectFrom("recent_orders")
|
|
170
|
+
.selectAll()
|
|
171
|
+
.execute();
|
|
172
|
+
|
|
135
173
|
// ============================================
|
|
136
174
|
// KEY PATTERNS SUMMARY
|
|
137
175
|
// ============================================
|
|
@@ -156,7 +194,12 @@ const categoryHierarchy = await db
|
|
|
156
194
|
- Base case UNION ALL recursive case
|
|
157
195
|
- Recursive case joins to the CTE itself
|
|
158
196
|
|
|
159
|
-
5.
|
|
197
|
+
5. MATERIALIZED / NOT MATERIALIZED (PostgreSQL):
|
|
198
|
+
- First arg becomes a callback: (cte) => cte("name").materialized()
|
|
199
|
+
- materialized(): compute once (expensive + reused, or optimization fence)
|
|
200
|
+
- notMaterialized(): let the planner inline it (push predicates down)
|
|
201
|
+
|
|
202
|
+
6. When to use CTEs:
|
|
160
203
|
- Complex multi-step aggregations
|
|
161
204
|
- Reusing a subquery multiple times
|
|
162
205
|
- Breaking down complex logic
|
|
@@ -240,6 +240,27 @@ const filteredProducts = await db
|
|
|
240
240
|
.$if(conditions.length > 0, (qb) => qb.where((eb) => eb.and(conditions)))
|
|
241
241
|
.execute();
|
|
242
242
|
|
|
243
|
+
// ============================================
|
|
244
|
+
// DYNAMIC COLUMN REFERENCES (db.dynamic)
|
|
245
|
+
// ============================================
|
|
246
|
+
|
|
247
|
+
// When a column name is only known at runtime (e.g. a user-chosen sort field),
|
|
248
|
+
// db.dynamic.ref() injects it as a properly-quoted identifier — NOT as a value,
|
|
249
|
+
// and without dropping to sql``. Validate the name against an allowlist first;
|
|
250
|
+
// dynamic refs bypass the compile-time column check.
|
|
251
|
+
const sortable = ["first_name", "last_name", "email"] as const;
|
|
252
|
+
type Sortable = (typeof sortable)[number];
|
|
253
|
+
|
|
254
|
+
async function getUsersSortedBy(column: Sortable) {
|
|
255
|
+
const { ref } = db.dynamic;
|
|
256
|
+
return db
|
|
257
|
+
.selectFrom("user")
|
|
258
|
+
.select(["id", "email"])
|
|
259
|
+
.orderBy(ref(column)) // runtime column, still quoted as an identifier
|
|
260
|
+
.execute();
|
|
261
|
+
}
|
|
262
|
+
// ref("first_name") -> "first_name" (an identifier), never a bound parameter
|
|
263
|
+
|
|
243
264
|
// ============================================
|
|
244
265
|
// KEY PATTERNS SUMMARY
|
|
245
266
|
// ============================================
|
|
@@ -269,4 +290,9 @@ const filteredProducts = await db
|
|
|
269
290
|
6. Expression<SqlBool>[] arrays:
|
|
270
291
|
- Build conditions dynamically
|
|
271
292
|
- Combine with eb.and([...]) or eb.or([...])
|
|
293
|
+
|
|
294
|
+
7. Dynamic column references: db.dynamic.ref(name)
|
|
295
|
+
- For runtime-chosen columns (e.g. sort fields)
|
|
296
|
+
- Emits a quoted identifier, not a bound value
|
|
297
|
+
- Bypasses the compile-time column check — allowlist the name first
|
|
272
298
|
*/
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-Text Search (PostgreSQL)
|
|
3
|
+
* tsvector / tsquery matching with the @@ operator.
|
|
4
|
+
*
|
|
5
|
+
* Kysely has no dedicated FTS helpers, but @@ IS in its operator allowlist, so
|
|
6
|
+
* you build the document (to_tsvector / a tsvector column) and the query
|
|
7
|
+
* (to_tsquery family) with sql`` fragments and let @@ stay a typed operator.
|
|
8
|
+
* Keep columns type-checked via sql.ref(); only the FTS functions are raw.
|
|
9
|
+
*
|
|
10
|
+
* tsquery builders:
|
|
11
|
+
* to_tsquery - operator syntax: 'cat & dog', 'cat | dog', 'cat & !dog'
|
|
12
|
+
* plainto_tsquery - plain words, ANDed together (user-friendly)
|
|
13
|
+
* websearch_to_tsquery - Google-style: quotes for phrases, - to exclude
|
|
14
|
+
*/
|
|
15
|
+
import { db } from "./db";
|
|
16
|
+
import { sql } from "kysely";
|
|
17
|
+
|
|
18
|
+
// ============================================
|
|
19
|
+
// MATCH ON A COMPUTED tsvector
|
|
20
|
+
// ============================================
|
|
21
|
+
|
|
22
|
+
// to_tsvector(...) @@ to_tsquery(...). @@ stays a real operator, so the LHS and
|
|
23
|
+
// RHS are the only raw parts; the column ref is validated by sql.ref().
|
|
24
|
+
const matches = await db
|
|
25
|
+
.selectFrom("document")
|
|
26
|
+
.selectAll()
|
|
27
|
+
.where(
|
|
28
|
+
sql`to_tsvector('english', ${sql.ref("body")})`,
|
|
29
|
+
"@@",
|
|
30
|
+
sql`to_tsquery('english', ${sql.lit("cat & dog")})`
|
|
31
|
+
)
|
|
32
|
+
.execute();
|
|
33
|
+
// SQL: where to_tsvector('english', "body") @@ to_tsquery('english', 'cat & dog')
|
|
34
|
+
|
|
35
|
+
// plainto_tsquery — turn free-text user input into an ANDed query safely
|
|
36
|
+
const userSearch = async (term: string) =>
|
|
37
|
+
db
|
|
38
|
+
.selectFrom("document")
|
|
39
|
+
.selectAll()
|
|
40
|
+
.where(
|
|
41
|
+
sql`to_tsvector('english', ${sql.ref("body")})`,
|
|
42
|
+
"@@",
|
|
43
|
+
sql`plainto_tsquery('english', ${term})`
|
|
44
|
+
)
|
|
45
|
+
.execute();
|
|
46
|
+
|
|
47
|
+
// websearch_to_tsquery — Google-style syntax ("phrase", -exclude, OR)
|
|
48
|
+
const webSearch = async (term: string) =>
|
|
49
|
+
db
|
|
50
|
+
.selectFrom("document")
|
|
51
|
+
.selectAll()
|
|
52
|
+
.where(
|
|
53
|
+
sql`to_tsvector('english', ${sql.ref("body")})`,
|
|
54
|
+
"@@",
|
|
55
|
+
sql`websearch_to_tsquery('english', ${term})`
|
|
56
|
+
)
|
|
57
|
+
.execute();
|
|
58
|
+
|
|
59
|
+
// ============================================
|
|
60
|
+
// MATCH ON A STORED tsvector COLUMN (preferred)
|
|
61
|
+
// ============================================
|
|
62
|
+
|
|
63
|
+
// Production setups store a tsvector column (often a GENERATED column with a
|
|
64
|
+
// GIN index) instead of computing to_tsvector at query time. Then @@ works
|
|
65
|
+
// directly against the column name — fully typed on the left.
|
|
66
|
+
const fastMatches = await db
|
|
67
|
+
.selectFrom("document")
|
|
68
|
+
.selectAll()
|
|
69
|
+
.where("search_vector", "@@", sql`to_tsquery('english', ${sql.lit("cat")})`)
|
|
70
|
+
.execute();
|
|
71
|
+
// SQL: where "search_vector" @@ to_tsquery('english', 'cat')
|
|
72
|
+
|
|
73
|
+
// ============================================
|
|
74
|
+
// RELEVANCE RANKING (ts_rank)
|
|
75
|
+
// ============================================
|
|
76
|
+
|
|
77
|
+
// ts_rank has no helper — use sql<number> and order by the alias.
|
|
78
|
+
const ranked = await db
|
|
79
|
+
.selectFrom("document")
|
|
80
|
+
.select((eb) => [
|
|
81
|
+
"id",
|
|
82
|
+
"title",
|
|
83
|
+
sql<number>`ts_rank(${eb.ref(
|
|
84
|
+
"search_vector"
|
|
85
|
+
)}, websearch_to_tsquery('english', ${"cat dog"}))`.as("rank"),
|
|
86
|
+
])
|
|
87
|
+
.where(
|
|
88
|
+
"search_vector",
|
|
89
|
+
"@@",
|
|
90
|
+
sql`websearch_to_tsquery('english', ${"cat dog"})`
|
|
91
|
+
)
|
|
92
|
+
.orderBy("rank", "desc")
|
|
93
|
+
.limit(20)
|
|
94
|
+
.execute();
|
|
95
|
+
|
|
96
|
+
// ============================================
|
|
97
|
+
// KEY PATTERNS SUMMARY
|
|
98
|
+
// ============================================
|
|
99
|
+
|
|
100
|
+
/*
|
|
101
|
+
1. @@ is a real Kysely operator — keep it as the operator and put the FTS
|
|
102
|
+
functions on either side as sql`` fragments. Don't wrap the whole predicate.
|
|
103
|
+
|
|
104
|
+
2. Validate columns with sql.ref(); a stored tsvector column can be the typed
|
|
105
|
+
LHS directly (where("search_vector", "@@", ...)).
|
|
106
|
+
|
|
107
|
+
3. Query builders by input source:
|
|
108
|
+
- to_tsquery: you control operator syntax (& | !).
|
|
109
|
+
- plainto_tsquery: untrusted plain words, ANDed.
|
|
110
|
+
- websearch_to_tsquery: Google-style user input.
|
|
111
|
+
|
|
112
|
+
4. Rank with ts_rank/ts_rank_cd via sql<number>, then orderBy the alias.
|
|
113
|
+
For performance, store a tsvector column with a GIN index rather than
|
|
114
|
+
computing to_tsvector() per query.
|
|
115
|
+
*/
|
|
@@ -183,6 +183,53 @@ const usersWithSummary = await db
|
|
|
183
183
|
.where("u.role", "=", "admin")
|
|
184
184
|
.execute();
|
|
185
185
|
|
|
186
|
+
// ============================================
|
|
187
|
+
// LATERAL JOINS (PostgreSQL)
|
|
188
|
+
// ============================================
|
|
189
|
+
|
|
190
|
+
// A LATERAL subquery can reference columns from earlier tables in the FROM
|
|
191
|
+
// clause via whereRef — the subquery runs once per outer row. Use the
|
|
192
|
+
// *Lateral methods + join.onTrue() (the join condition lives inside the
|
|
193
|
+
// subquery, so the ON is just "true").
|
|
194
|
+
|
|
195
|
+
// Top-N-per-group with a per-row LIMIT — the classic LATERAL use case.
|
|
196
|
+
// Get each user's 3 most recent orders.
|
|
197
|
+
const usersWithRecentOrders = await db
|
|
198
|
+
.selectFrom("user as u")
|
|
199
|
+
.innerJoinLateral(
|
|
200
|
+
(eb) =>
|
|
201
|
+
eb
|
|
202
|
+
.selectFrom("order as o")
|
|
203
|
+
.select(["o.id", "o.total_amount", "o.created_at"])
|
|
204
|
+
.whereRef("o.user_id", "=", "u.id") // references the outer row
|
|
205
|
+
.orderBy("o.created_at", "desc")
|
|
206
|
+
.limit(3)
|
|
207
|
+
.as("recent"),
|
|
208
|
+
(join) => join.onTrue()
|
|
209
|
+
)
|
|
210
|
+
.select(["u.email", "recent.id", "recent.total_amount", "recent.created_at"])
|
|
211
|
+
.execute();
|
|
212
|
+
// SQL: from "user" as "u" inner join lateral (... where "o"."user_id" = "u"."id"
|
|
213
|
+
// order by "o"."created_at" desc limit $1) as "recent" on true
|
|
214
|
+
|
|
215
|
+
// leftJoinLateral keeps outer rows even when the subquery returns nothing.
|
|
216
|
+
const usersWithMaybeOrder = await db
|
|
217
|
+
.selectFrom("user as u")
|
|
218
|
+
.leftJoinLateral(
|
|
219
|
+
(eb) =>
|
|
220
|
+
eb
|
|
221
|
+
.selectFrom("order as o")
|
|
222
|
+
.select(["o.id", "o.total_amount"])
|
|
223
|
+
.whereRef("o.user_id", "=", "u.id")
|
|
224
|
+
.orderBy("o.created_at", "desc")
|
|
225
|
+
.limit(1)
|
|
226
|
+
.as("latest"),
|
|
227
|
+
(join) => join.onTrue()
|
|
228
|
+
)
|
|
229
|
+
.select(["u.email", "latest.id", "latest.total_amount"]) // null if no orders
|
|
230
|
+
.execute();
|
|
231
|
+
// crossJoinLateral also exists (no ON clause at all).
|
|
232
|
+
|
|
186
233
|
// ============================================
|
|
187
234
|
// KEY PATTERNS SUMMARY
|
|
188
235
|
// ============================================
|
|
@@ -203,4 +250,9 @@ const usersWithSummary = await db
|
|
|
203
250
|
|
|
204
251
|
4. Cross joins: join.on(sql`true`, "=", sql`true`)
|
|
205
252
|
- For joining unrelated data (like CTE summaries)
|
|
253
|
+
|
|
254
|
+
5. Lateral joins: .innerJoinLateral((eb) => eb...whereRef(outer).as("x"),
|
|
255
|
+
(join) => join.onTrue())
|
|
256
|
+
- Subquery can reference outer rows; runs per row.
|
|
257
|
+
- Best tool for top-N-per-group with a per-row LIMIT.
|
|
206
258
|
*/
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row Locking (SELECT ... FOR ...)
|
|
3
|
+
* Pessimistic locks for read-modify-write and job-queue patterns.
|
|
4
|
+
*
|
|
5
|
+
* Lock strength (weakest -> strongest):
|
|
6
|
+
* .forKeyShare() -> FOR KEY SHARE
|
|
7
|
+
* .forShare() -> FOR SHARE
|
|
8
|
+
* .forNoKeyUpdate() -> FOR NO KEY UPDATE
|
|
9
|
+
* .forUpdate() -> FOR UPDATE
|
|
10
|
+
*
|
|
11
|
+
* Wait behavior (chain after a lock):
|
|
12
|
+
* .skipLocked() -> SKIP LOCKED (ignore already-locked rows)
|
|
13
|
+
* .noWait() -> NOWAIT (error instead of blocking)
|
|
14
|
+
*/
|
|
15
|
+
import { db } from "./db";
|
|
16
|
+
|
|
17
|
+
// ============================================
|
|
18
|
+
// BASIC LOCKS
|
|
19
|
+
// ============================================
|
|
20
|
+
|
|
21
|
+
// FOR UPDATE — lock the selected rows until the transaction ends.
|
|
22
|
+
// Run inside a transaction; the lock is released on commit/rollback.
|
|
23
|
+
const lockedOrder = await db
|
|
24
|
+
.selectFrom("order")
|
|
25
|
+
.selectAll()
|
|
26
|
+
.where("id", "=", 1)
|
|
27
|
+
.forUpdate()
|
|
28
|
+
.executeTakeFirst();
|
|
29
|
+
// SQL: select * from "order" where "id" = $1 for update
|
|
30
|
+
|
|
31
|
+
// FOR SHARE — allow concurrent reads-with-lock, block writers.
|
|
32
|
+
const sharedRow = await db
|
|
33
|
+
.selectFrom("product")
|
|
34
|
+
.selectAll()
|
|
35
|
+
.where("id", "=", 1)
|
|
36
|
+
.forShare()
|
|
37
|
+
.executeTakeFirst();
|
|
38
|
+
|
|
39
|
+
// ============================================
|
|
40
|
+
// JOB QUEUE: FOR UPDATE SKIP LOCKED
|
|
41
|
+
// ============================================
|
|
42
|
+
|
|
43
|
+
// The canonical worker pattern: each worker grabs the next available job and
|
|
44
|
+
// SKIP LOCKED steps over rows other workers already hold — no blocking, no
|
|
45
|
+
// double-processing. Pair with a RETURNING update or do the work in the same tx.
|
|
46
|
+
const nextJobs = await db
|
|
47
|
+
.selectFrom("job")
|
|
48
|
+
.selectAll()
|
|
49
|
+
.where("status", "=", "pending")
|
|
50
|
+
.orderBy("created_at")
|
|
51
|
+
.limit(10)
|
|
52
|
+
.forUpdate()
|
|
53
|
+
.skipLocked()
|
|
54
|
+
.execute();
|
|
55
|
+
// SQL: ... for update skip locked
|
|
56
|
+
|
|
57
|
+
// NOWAIT — fail fast instead of waiting for a contended lock
|
|
58
|
+
const grabOrFail = await db
|
|
59
|
+
.selectFrom("order")
|
|
60
|
+
.selectAll()
|
|
61
|
+
.where("id", "=", 1)
|
|
62
|
+
.forUpdate()
|
|
63
|
+
.noWait()
|
|
64
|
+
.executeTakeFirst();
|
|
65
|
+
// SQL: ... for update nowait
|
|
66
|
+
|
|
67
|
+
// ============================================
|
|
68
|
+
// WEAKER UPDATE LOCKS
|
|
69
|
+
// ============================================
|
|
70
|
+
|
|
71
|
+
// FOR NO KEY UPDATE — like FOR UPDATE but doesn't block FK reference inserts.
|
|
72
|
+
// FOR KEY SHARE — weakest; blocks only key-changing updates/deletes.
|
|
73
|
+
const noKeyLock = await db
|
|
74
|
+
.selectFrom("user")
|
|
75
|
+
.selectAll()
|
|
76
|
+
.where("id", "=", 1)
|
|
77
|
+
.forNoKeyUpdate()
|
|
78
|
+
.executeTakeFirst();
|
|
79
|
+
|
|
80
|
+
// ============================================
|
|
81
|
+
// KEY PATTERNS SUMMARY
|
|
82
|
+
// ============================================
|
|
83
|
+
|
|
84
|
+
/*
|
|
85
|
+
1. Always lock inside a transaction — locks release at commit/rollback.
|
|
86
|
+
|
|
87
|
+
2. Job queue: .forUpdate().skipLocked() with .limit() + ORDER BY.
|
|
88
|
+
Workers never block each other and never grab the same row.
|
|
89
|
+
|
|
90
|
+
3. .noWait() throws on contention; .skipLocked() silently skips — pick per use.
|
|
91
|
+
|
|
92
|
+
4. Lock strength: forKeyShare < forShare < forNoKeyUpdate < forUpdate.
|
|
93
|
+
Prefer the weakest lock that still prevents your race.
|
|
94
|
+
*/
|