@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
|
@@ -121,6 +121,59 @@ const complexFilter = await db
|
|
|
121
121
|
)
|
|
122
122
|
.execute();
|
|
123
123
|
|
|
124
|
+
// ============================================
|
|
125
|
+
// BETWEEN — use eb.between(), NOT the "between" operator
|
|
126
|
+
// ============================================
|
|
127
|
+
|
|
128
|
+
// CORRECT: eb.between(col, lo, hi) / eb.betweenSymmetric(col, lo, hi)
|
|
129
|
+
const midPriced = await db
|
|
130
|
+
.selectFrom("product")
|
|
131
|
+
.selectAll()
|
|
132
|
+
.where((eb) => eb.between("price", "50", "100"))
|
|
133
|
+
.execute();
|
|
134
|
+
// SQL: where "price" between $1 and $2
|
|
135
|
+
|
|
136
|
+
// betweenSymmetric handles bounds given in either order (swaps if lo > hi)
|
|
137
|
+
const inRange = await db
|
|
138
|
+
.selectFrom("product")
|
|
139
|
+
.selectAll()
|
|
140
|
+
.where((eb) => eb.betweenSymmetric("price", "100", "50"))
|
|
141
|
+
.execute();
|
|
142
|
+
// SQL: where "price" between symmetric $1 and $2
|
|
143
|
+
|
|
144
|
+
// GOTCHA: the string-operator form generates INVALID SQL — do NOT use it.
|
|
145
|
+
// .where("price", "between", ["50", "100"])
|
|
146
|
+
// compiles to: "price" between ($1, $2) -- a tuple, which is a Postgres
|
|
147
|
+
// syntax error ("between (a, b)" is not "between a and b"). Always use
|
|
148
|
+
// eb.between() / eb.betweenSymmetric() instead.
|
|
149
|
+
|
|
150
|
+
// ============================================
|
|
151
|
+
// ANY (array / subquery membership)
|
|
152
|
+
// ============================================
|
|
153
|
+
|
|
154
|
+
// value = ANY(array_column) — type-safe with eb.fn.any
|
|
155
|
+
const taggedPremium = await db
|
|
156
|
+
.selectFrom("product")
|
|
157
|
+
.selectAll()
|
|
158
|
+
.where((eb) => eb(eb.val("premium"), "=", eb.fn.any("tags")))
|
|
159
|
+
.execute();
|
|
160
|
+
// SQL: where $1 = any("tags")
|
|
161
|
+
|
|
162
|
+
// value = ANY(subquery)
|
|
163
|
+
const ownersOfDogs = await db
|
|
164
|
+
.selectFrom("user")
|
|
165
|
+
.selectAll()
|
|
166
|
+
.where((eb) =>
|
|
167
|
+
eb(
|
|
168
|
+
eb.val("dog"),
|
|
169
|
+
"=",
|
|
170
|
+
eb.fn.any(
|
|
171
|
+
eb.selectFrom("pet").select("species").whereRef("pet.owner_id", "=", "user.id")
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
.execute();
|
|
176
|
+
|
|
124
177
|
// ============================================
|
|
125
178
|
// KEY PATTERNS SUMMARY
|
|
126
179
|
// ============================================
|
|
@@ -143,4 +196,10 @@ const complexFilter = await db
|
|
|
143
196
|
4. eb() inside where callbacks
|
|
144
197
|
- eb("column", "=", value) creates comparison
|
|
145
198
|
- Returns Expression<SqlBool> for composability
|
|
199
|
+
|
|
200
|
+
5. BETWEEN: use eb.between(col, lo, hi) / eb.betweenSymmetric(...)
|
|
201
|
+
- The "between" string operator emits invalid SQL ("between (a, b)") — avoid.
|
|
202
|
+
|
|
203
|
+
6. ANY: eb(eb.val(x), "=", eb.fn.any("array_col" | subquery))
|
|
204
|
+
- Membership test against an array column or a subquery result.
|
|
146
205
|
*/
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Set Operations (UNION / INTERSECT / EXCEPT)
|
|
3
|
+
* Combine the rows of two compatible queries.
|
|
4
|
+
*
|
|
5
|
+
* .union(q) / .unionAll(q) -> UNION / UNION ALL
|
|
6
|
+
* .intersect(q) / .intersectAll(q) -> INTERSECT / INTERSECT ALL
|
|
7
|
+
* .except(q) / .exceptAll(q) -> EXCEPT / EXCEPT ALL
|
|
8
|
+
*
|
|
9
|
+
* The plain forms remove duplicates; the *All forms keep them (and are cheaper).
|
|
10
|
+
* Both sides must select the same number of columns with compatible types and
|
|
11
|
+
* matching output names — align them with `as` aliases when they differ.
|
|
12
|
+
*/
|
|
13
|
+
import { db } from "./db";
|
|
14
|
+
|
|
15
|
+
// ============================================
|
|
16
|
+
// UNION — distinct rows from both queries
|
|
17
|
+
// ============================================
|
|
18
|
+
|
|
19
|
+
// Align column names with aliases so both branches produce the same shape.
|
|
20
|
+
const contacts = await db
|
|
21
|
+
.selectFrom("user")
|
|
22
|
+
.select(["id", "email as contact"])
|
|
23
|
+
.union(db.selectFrom("supplier").select(["id", "contact_email as contact"]))
|
|
24
|
+
.execute();
|
|
25
|
+
// SQL: select "id", "email" as "contact" from "user"
|
|
26
|
+
// union select "id", "contact_email" as "contact" from "supplier"
|
|
27
|
+
|
|
28
|
+
// UNION ALL — keep duplicates (faster; no dedup pass)
|
|
29
|
+
const allEvents = await db
|
|
30
|
+
.selectFrom("order")
|
|
31
|
+
.select(["id", "created_at"])
|
|
32
|
+
.unionAll(db.selectFrom("review").select(["id", "created_at"]))
|
|
33
|
+
.execute();
|
|
34
|
+
|
|
35
|
+
// ============================================
|
|
36
|
+
// INTERSECT — rows present in BOTH queries
|
|
37
|
+
// ============================================
|
|
38
|
+
|
|
39
|
+
const usersWhoAreAlsoReviewers = await db
|
|
40
|
+
.selectFrom("order")
|
|
41
|
+
.select("user_id")
|
|
42
|
+
.intersect(db.selectFrom("review").select("user_id"))
|
|
43
|
+
.execute();
|
|
44
|
+
|
|
45
|
+
// INTERSECT ALL keeps duplicate matches
|
|
46
|
+
const repeated = await db
|
|
47
|
+
.selectFrom("order")
|
|
48
|
+
.select("user_id")
|
|
49
|
+
.intersectAll(db.selectFrom("review").select("user_id"))
|
|
50
|
+
.execute();
|
|
51
|
+
|
|
52
|
+
// ============================================
|
|
53
|
+
// EXCEPT — rows in the FIRST query but not the second
|
|
54
|
+
// ============================================
|
|
55
|
+
|
|
56
|
+
// Users who placed an order but never wrote a review.
|
|
57
|
+
const ordersWithoutReviews = await db
|
|
58
|
+
.selectFrom("order")
|
|
59
|
+
.select("user_id")
|
|
60
|
+
.except(db.selectFrom("review").select("user_id"))
|
|
61
|
+
.execute();
|
|
62
|
+
|
|
63
|
+
const ordersWithoutReviewsKeepDupes = await db
|
|
64
|
+
.selectFrom("order")
|
|
65
|
+
.select("user_id")
|
|
66
|
+
.exceptAll(db.selectFrom("review").select("user_id"))
|
|
67
|
+
.execute();
|
|
68
|
+
|
|
69
|
+
// ============================================
|
|
70
|
+
// ORDERING THE COMBINED RESULT
|
|
71
|
+
// ============================================
|
|
72
|
+
|
|
73
|
+
// orderBy/limit after a set op apply to the whole combined result.
|
|
74
|
+
const recentCombined = await db
|
|
75
|
+
.selectFrom("order")
|
|
76
|
+
.select(["id", "created_at"])
|
|
77
|
+
.unionAll(db.selectFrom("review").select(["id", "created_at"]))
|
|
78
|
+
.orderBy("created_at", "desc")
|
|
79
|
+
.limit(20)
|
|
80
|
+
.execute();
|
|
81
|
+
|
|
82
|
+
// ============================================
|
|
83
|
+
// KEY PATTERNS SUMMARY
|
|
84
|
+
// ============================================
|
|
85
|
+
|
|
86
|
+
/*
|
|
87
|
+
1. Both branches must select the same columns (count + compatible types) with
|
|
88
|
+
matching output names — use `as` to line them up.
|
|
89
|
+
|
|
90
|
+
2. Plain forms dedupe; *All forms keep duplicates and skip the dedup pass.
|
|
91
|
+
Reach for unionAll/intersectAll/exceptAll unless you actually need DISTINCT.
|
|
92
|
+
|
|
93
|
+
3. EXCEPT/INTERSECT are set-difference / set-intersection on whole rows.
|
|
94
|
+
|
|
95
|
+
4. orderBy/limit chained after the set op apply to the combined result, not the
|
|
96
|
+
individual branches.
|
|
97
|
+
*/
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Window Functions
|
|
3
|
+
* Ranking, value, and aggregate functions over OVER() windows.
|
|
4
|
+
*
|
|
5
|
+
* Two builders cover almost everything:
|
|
6
|
+
* - eb.fn.agg<T>("NAME", [args]).over(...) for named window functions
|
|
7
|
+
* (ROW_NUMBER, RANK, LAG, ...) that have no dedicated helper
|
|
8
|
+
* - eb.fn.sum/count/avg/min/max(col).over(...) for windowed aggregates
|
|
9
|
+
*
|
|
10
|
+
* The OVER() body is built with a callback: (ob) => ob.partitionBy(...).orderBy(...)
|
|
11
|
+
* Call .over() with no callback for an empty OVER ().
|
|
12
|
+
*
|
|
13
|
+
* The one thing the builder CANNOT express is a frame clause
|
|
14
|
+
* (ROWS/RANGE/GROUPS BETWEEN) — see WINDOW FRAMES below.
|
|
15
|
+
*/
|
|
16
|
+
import { db } from "./db";
|
|
17
|
+
import { sql } from "kysely";
|
|
18
|
+
|
|
19
|
+
// ============================================
|
|
20
|
+
// RANKING FUNCTIONS
|
|
21
|
+
// ============================================
|
|
22
|
+
|
|
23
|
+
// ROW_NUMBER / RANK / DENSE_RANK — use eb.fn.agg (no dedicated helper)
|
|
24
|
+
// Rank products by price within each category.
|
|
25
|
+
const rankedProducts = await db
|
|
26
|
+
.selectFrom("product")
|
|
27
|
+
.select((eb) => [
|
|
28
|
+
"id",
|
|
29
|
+
"name",
|
|
30
|
+
"category_id",
|
|
31
|
+
eb.fn
|
|
32
|
+
.agg<number>("ROW_NUMBER")
|
|
33
|
+
.over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
|
|
34
|
+
.as("row_num"),
|
|
35
|
+
eb.fn
|
|
36
|
+
.agg<number>("RANK")
|
|
37
|
+
.over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
|
|
38
|
+
.as("price_rank"),
|
|
39
|
+
eb.fn
|
|
40
|
+
.agg<number>("DENSE_RANK")
|
|
41
|
+
.over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
|
|
42
|
+
.as("dense_rank"),
|
|
43
|
+
])
|
|
44
|
+
.execute();
|
|
45
|
+
// SQL: ROW_NUMBER() over(partition by "category_id" order by "price" desc) as "row_num"
|
|
46
|
+
|
|
47
|
+
// NTILE / PERCENT_RANK / CUME_DIST — NTILE takes an argument (use sql.lit)
|
|
48
|
+
const productBuckets = await db
|
|
49
|
+
.selectFrom("product")
|
|
50
|
+
.select((eb) => [
|
|
51
|
+
"id",
|
|
52
|
+
eb.fn
|
|
53
|
+
.agg<number>("NTILE", [sql.lit(4)])
|
|
54
|
+
.over((ob) => ob.orderBy("price", "desc"))
|
|
55
|
+
.as("price_quartile"),
|
|
56
|
+
eb.fn
|
|
57
|
+
.agg<number>("PERCENT_RANK")
|
|
58
|
+
.over((ob) => ob.orderBy("price", "desc"))
|
|
59
|
+
.as("pct_rank"),
|
|
60
|
+
])
|
|
61
|
+
.execute();
|
|
62
|
+
// SQL: NTILE(4) over(order by "price" desc) as "price_quartile"
|
|
63
|
+
|
|
64
|
+
// ============================================
|
|
65
|
+
// VALUE FUNCTIONS (LAG / LEAD / FIRST_VALUE / NTH_VALUE)
|
|
66
|
+
// ============================================
|
|
67
|
+
|
|
68
|
+
// LAG / LEAD — args: (column, offset?, default?). Wrap literals in sql.lit().
|
|
69
|
+
// Type the result yourself; nullable unless you supply a default.
|
|
70
|
+
const orderTrends = await db
|
|
71
|
+
.selectFrom("order")
|
|
72
|
+
.select((eb) => [
|
|
73
|
+
"id",
|
|
74
|
+
"created_at",
|
|
75
|
+
"total_amount",
|
|
76
|
+
eb.fn
|
|
77
|
+
.agg<number | null>("LAG", ["total_amount", sql.lit(1)])
|
|
78
|
+
.over((ob) => ob.partitionBy("user_id").orderBy("created_at"))
|
|
79
|
+
.as("prev_amount"),
|
|
80
|
+
eb.fn
|
|
81
|
+
.agg<number | null>("LEAD", ["total_amount", sql.lit(1)])
|
|
82
|
+
.over((ob) => ob.partitionBy("user_id").orderBy("created_at"))
|
|
83
|
+
.as("next_amount"),
|
|
84
|
+
// With a default value (no nulls): LAG(total_amount, 1, 0)
|
|
85
|
+
eb.fn
|
|
86
|
+
.agg<number>("LAG", ["total_amount", sql.lit(1), sql.lit(0)])
|
|
87
|
+
.over((ob) => ob.partitionBy("user_id").orderBy("created_at"))
|
|
88
|
+
.as("prev_amount_or_zero"),
|
|
89
|
+
])
|
|
90
|
+
.execute();
|
|
91
|
+
// SQL: LAG("total_amount", 1, 0) over(partition by "user_id" order by "created_at") as "prev_amount_or_zero"
|
|
92
|
+
|
|
93
|
+
// FIRST_VALUE / NTH_VALUE
|
|
94
|
+
const categoryExtremes = await db
|
|
95
|
+
.selectFrom("product")
|
|
96
|
+
.select((eb) => [
|
|
97
|
+
"id",
|
|
98
|
+
"category_id",
|
|
99
|
+
eb.fn
|
|
100
|
+
.agg<number>("FIRST_VALUE", ["price"])
|
|
101
|
+
.over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
|
|
102
|
+
.as("highest_in_category"),
|
|
103
|
+
eb.fn
|
|
104
|
+
.agg<number | null>("NTH_VALUE", ["price", sql.lit(2)])
|
|
105
|
+
.over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
|
|
106
|
+
.as("second_highest"),
|
|
107
|
+
])
|
|
108
|
+
.execute();
|
|
109
|
+
|
|
110
|
+
// ============================================
|
|
111
|
+
// AGGREGATE WINDOWS (sum/count/avg/min/max .over())
|
|
112
|
+
// ============================================
|
|
113
|
+
|
|
114
|
+
// Empty OVER () — aggregate over the whole result set, no collapsing of rows
|
|
115
|
+
const withGrandTotal = await db
|
|
116
|
+
.selectFrom("order")
|
|
117
|
+
.select((eb) => [
|
|
118
|
+
"id",
|
|
119
|
+
"total_amount",
|
|
120
|
+
eb.fn.sum<number>("total_amount").over().as("grand_total"),
|
|
121
|
+
])
|
|
122
|
+
.execute();
|
|
123
|
+
// SQL: sum("total_amount") over() as "grand_total"
|
|
124
|
+
|
|
125
|
+
// Running total — ORDER BY without a frame defaults to
|
|
126
|
+
// "RANGE UNBOUNDED PRECEDING -> CURRENT ROW" (cumulative)
|
|
127
|
+
const runningTotals = await db
|
|
128
|
+
.selectFrom("order")
|
|
129
|
+
.select((eb) => [
|
|
130
|
+
"id",
|
|
131
|
+
"created_at",
|
|
132
|
+
"total_amount",
|
|
133
|
+
eb.fn
|
|
134
|
+
.sum<number>("total_amount")
|
|
135
|
+
.over((ob) => ob.orderBy("created_at"))
|
|
136
|
+
.as("running_total"),
|
|
137
|
+
])
|
|
138
|
+
.execute();
|
|
139
|
+
// SQL: sum("total_amount") over(order by "created_at") as "running_total"
|
|
140
|
+
|
|
141
|
+
// Partition total + percent-of-partition (mix a window into a raw expression)
|
|
142
|
+
const sharePerCategory = await db
|
|
143
|
+
.selectFrom("product")
|
|
144
|
+
.select((eb) => [
|
|
145
|
+
"id",
|
|
146
|
+
"category_id",
|
|
147
|
+
"price",
|
|
148
|
+
eb.fn
|
|
149
|
+
.sum<number>("price")
|
|
150
|
+
.over((ob) => ob.partitionBy("category_id"))
|
|
151
|
+
.as("category_total"),
|
|
152
|
+
sql<number>`${eb.ref("price")} * 100.0 / ${eb.fn
|
|
153
|
+
.sum("price")
|
|
154
|
+
.over((ob) => ob.partitionBy("category_id"))}`.as("pct_of_category"),
|
|
155
|
+
])
|
|
156
|
+
.execute();
|
|
157
|
+
|
|
158
|
+
// ============================================
|
|
159
|
+
// FILTER + DISTINCT IN WINDOWS (PostgreSQL)
|
|
160
|
+
// ============================================
|
|
161
|
+
|
|
162
|
+
// .filterWhere() composes with .over() — conditional windowed aggregation
|
|
163
|
+
const activeCounts = await db
|
|
164
|
+
.selectFrom("order")
|
|
165
|
+
.select((eb) => [
|
|
166
|
+
"id",
|
|
167
|
+
"user_id",
|
|
168
|
+
eb.fn
|
|
169
|
+
.countAll<number>()
|
|
170
|
+
.filterWhere("status", "=", "completed")
|
|
171
|
+
.over((ob) => ob.partitionBy("user_id"))
|
|
172
|
+
.as("completed_orders_for_user"),
|
|
173
|
+
])
|
|
174
|
+
.execute();
|
|
175
|
+
// SQL: count(*) filter(where "status" = $1) over(partition by "user_id")
|
|
176
|
+
|
|
177
|
+
// COUNT(DISTINCT ...) OVER (...)
|
|
178
|
+
const distinctStatuses = await db
|
|
179
|
+
.selectFrom("order")
|
|
180
|
+
.select((eb) => [
|
|
181
|
+
"id",
|
|
182
|
+
"user_id",
|
|
183
|
+
eb.fn
|
|
184
|
+
.count<number>("status")
|
|
185
|
+
.distinct()
|
|
186
|
+
.over((ob) => ob.partitionBy("user_id"))
|
|
187
|
+
.as("distinct_statuses_for_user"),
|
|
188
|
+
])
|
|
189
|
+
.execute();
|
|
190
|
+
// SQL: count(distinct "status") over(partition by "user_id")
|
|
191
|
+
|
|
192
|
+
// ============================================
|
|
193
|
+
// WINDOW FRAMES (ROWS/RANGE BETWEEN) — RAW SQL REQUIRED
|
|
194
|
+
// ============================================
|
|
195
|
+
|
|
196
|
+
// Kysely's OverBuilder exposes ONLY partitionBy + orderBy. There is no frame
|
|
197
|
+
// node in its AST at all (confirmed through 0.28, 0.29, and main), and
|
|
198
|
+
// .over() rejects a raw sql argument. Frame support is the open feature
|
|
199
|
+
// request kysely-org/kysely#505. So a frame clause must be written as raw SQL.
|
|
200
|
+
//
|
|
201
|
+
// You do NOT lose all type safety: interpolate eb.ref() for columns (validated)
|
|
202
|
+
// and sql.lit() for bounds, and type the result with sql<T>. Only the function
|
|
203
|
+
// name and the frame keywords are raw text.
|
|
204
|
+
|
|
205
|
+
// Moving average over the last 3 rows
|
|
206
|
+
const movingAverage = await db
|
|
207
|
+
.selectFrom("order")
|
|
208
|
+
.select((eb) => [
|
|
209
|
+
"id",
|
|
210
|
+
"created_at",
|
|
211
|
+
"total_amount",
|
|
212
|
+
sql<number>`avg(${eb.ref("total_amount")}) over (
|
|
213
|
+
order by ${eb.ref("created_at")}
|
|
214
|
+
rows between 2 preceding and current row
|
|
215
|
+
)`.as("moving_avg_3"),
|
|
216
|
+
])
|
|
217
|
+
.execute();
|
|
218
|
+
// SQL: avg("total_amount") over ( order by "created_at" rows between 2 preceding and current row )
|
|
219
|
+
|
|
220
|
+
// Cumulative sum with an explicit frame
|
|
221
|
+
const cumulative = await db
|
|
222
|
+
.selectFrom("order")
|
|
223
|
+
.select((eb) => [
|
|
224
|
+
"id",
|
|
225
|
+
sql<number>`sum(${eb.ref("total_amount")}) over (
|
|
226
|
+
partition by ${eb.ref("user_id")}
|
|
227
|
+
order by ${eb.ref("created_at")}
|
|
228
|
+
rows between unbounded preceding and current row
|
|
229
|
+
)`.as("cumulative_sum"),
|
|
230
|
+
])
|
|
231
|
+
.execute();
|
|
232
|
+
|
|
233
|
+
// ============================================
|
|
234
|
+
// COMMON PATTERNS
|
|
235
|
+
// ============================================
|
|
236
|
+
|
|
237
|
+
// Top-N per group: rank in a CTE, then filter in the outer query.
|
|
238
|
+
// (You can't filter on a window alias in the same SELECT's WHERE — windows are
|
|
239
|
+
// computed after WHERE — so the CTE/subquery wrapper is required.)
|
|
240
|
+
const top3ProductsPerCategory = await db
|
|
241
|
+
.with("ranked", (db) =>
|
|
242
|
+
db
|
|
243
|
+
.selectFrom("product")
|
|
244
|
+
.select((eb) => [
|
|
245
|
+
"id",
|
|
246
|
+
"name",
|
|
247
|
+
"category_id",
|
|
248
|
+
"price",
|
|
249
|
+
eb.fn
|
|
250
|
+
.agg<number>("ROW_NUMBER")
|
|
251
|
+
.over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
|
|
252
|
+
.as("rn"),
|
|
253
|
+
])
|
|
254
|
+
)
|
|
255
|
+
.selectFrom("ranked")
|
|
256
|
+
.selectAll()
|
|
257
|
+
.where("rn", "<=", 3)
|
|
258
|
+
.execute();
|
|
259
|
+
|
|
260
|
+
// Percent of total (empty OVER () as the denominator)
|
|
261
|
+
const pctOfTotal = await db
|
|
262
|
+
.selectFrom("product")
|
|
263
|
+
.select((eb) => [
|
|
264
|
+
"id",
|
|
265
|
+
"price",
|
|
266
|
+
sql<number>`round(${eb.ref("price")} * 100.0 / ${eb.fn
|
|
267
|
+
.sum("price")
|
|
268
|
+
.over()}, 2)`.as("pct_of_total"),
|
|
269
|
+
])
|
|
270
|
+
.execute();
|
|
271
|
+
|
|
272
|
+
// ============================================
|
|
273
|
+
// KEY PATTERNS SUMMARY
|
|
274
|
+
// ============================================
|
|
275
|
+
|
|
276
|
+
/*
|
|
277
|
+
1. Named window functions: eb.fn.agg<T>("ROW_NUMBER").over((ob) => ...)
|
|
278
|
+
- ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK, CUME_DIST
|
|
279
|
+
- LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE
|
|
280
|
+
- Function arguments go in the array; wrap literals in sql.lit().
|
|
281
|
+
|
|
282
|
+
2. Windowed aggregates: eb.fn.sum/count/avg/min/max(col).over((ob) => ...)
|
|
283
|
+
- .over() with no callback => OVER () over the whole result set.
|
|
284
|
+
- .filterWhere(...) and .distinct() compose with .over().
|
|
285
|
+
|
|
286
|
+
3. OVER body: (ob) => ob.partitionBy(col | [cols]).orderBy(col, "desc")
|
|
287
|
+
|
|
288
|
+
4. Frames (ROWS/RANGE BETWEEN) are NOT supported by the builder (issue #505).
|
|
289
|
+
Write the window as raw sql, keeping eb.ref() for columns and sql<T> for the
|
|
290
|
+
result type. Only the function name + frame keywords are raw.
|
|
291
|
+
|
|
292
|
+
5. Filtering on a window result (e.g. row_number = 1) needs a CTE/subquery:
|
|
293
|
+
windows are computed after WHERE, so wrap and filter in the outer query.
|
|
294
|
+
*/
|
|
@@ -76,7 +76,7 @@ From nuxt-auth-utils:
|
|
|
76
76
|
- `hashPassword`, `verifyPassword`
|
|
77
77
|
- `defineOAuth*EventHandler` (Google, GitHub, etc.)
|
|
78
78
|
|
|
79
|
-
**Need to import:** `z` from "zod"
|
|
79
|
+
**Need to import:** `z` from "zod" (zod 4 — error formatting is built in via `z.prettifyError()`; no `zod-validation-error` needed)
|
|
80
80
|
|
|
81
81
|
### Client-side
|
|
82
82
|
|
|
@@ -104,7 +104,7 @@ const query = await getValidatedQuery(event, z.object({
|
|
|
104
104
|
}));
|
|
105
105
|
|
|
106
106
|
const body = await readValidatedBody(event, z.object({
|
|
107
|
-
email: z.string().email()
|
|
107
|
+
email: z.email(), // Zod 4: top-level, not z.string().email()
|
|
108
108
|
name: z.string().min(1),
|
|
109
109
|
}));
|
|
110
110
|
```
|
|
@@ -33,7 +33,7 @@ Tasks live in `server/tasks/`. Directory structure = task name with colons:
|
|
|
33
33
|
import { z } from "zod";
|
|
34
34
|
|
|
35
35
|
const PayloadSchema = z.object({
|
|
36
|
-
to: z.string().email()
|
|
36
|
+
to: z.email(), // Zod 4: top-level, not z.string().email()
|
|
37
37
|
subject: z.string(),
|
|
38
38
|
body: z.string(),
|
|
39
39
|
});
|
|
@@ -43,25 +43,28 @@ const query = await getValidatedQuery(event, (data) => querySchema.parse(data));
|
|
|
43
43
|
|
|
44
44
|
## Pattern 3: safeParse for Better Errors
|
|
45
45
|
|
|
46
|
+
Zod 4 has a built-in `z.prettifyError()` — no `zod-validation-error` dependency needed:
|
|
47
|
+
|
|
46
48
|
```typescript
|
|
47
|
-
import {
|
|
49
|
+
import { z } from "zod";
|
|
48
50
|
|
|
49
51
|
const rawQuery = getQuery(event);
|
|
50
52
|
const result = querySchema.safeParse(rawQuery);
|
|
51
53
|
|
|
52
54
|
if (!result.success) {
|
|
53
|
-
console.error("Validation error:", result.error);
|
|
54
|
-
const userError = fromZodError(result.error); // User-friendly
|
|
55
|
+
console.error("Validation error:", z.treeifyError(result.error)); // structured dev log
|
|
55
56
|
throw createError({
|
|
56
57
|
statusCode: 400,
|
|
57
58
|
statusMessage: "Bad Request",
|
|
58
|
-
message:
|
|
59
|
+
message: z.prettifyError(result.error), // human-readable, e.g. "✖ Invalid email address\n → at email"
|
|
59
60
|
});
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
return result.data;
|
|
63
64
|
```
|
|
64
65
|
|
|
66
|
+
`z.prettifyError(err)` returns a readable multi-line string; `z.treeifyError(err)` returns a nested object keyed by field (the replacement for the deprecated `.format()`). Use `z.flattenError(err)` for a flat `{ formErrors, fieldErrors }` shape.
|
|
67
|
+
|
|
65
68
|
## Common Zod Patterns
|
|
66
69
|
|
|
67
70
|
### Query Parameters
|
|
@@ -90,7 +93,7 @@ const querySchema = z.object({
|
|
|
90
93
|
|
|
91
94
|
```typescript
|
|
92
95
|
const createUserSchema = z.object({
|
|
93
|
-
email: z.string().email()
|
|
96
|
+
email: z.email(), // Zod 4: top-level, NOT z.string().email()
|
|
94
97
|
name: z.string().min(1).max(100),
|
|
95
98
|
role: z.enum(["admin", "user"]).default("user"),
|
|
96
99
|
metadata: z.record(z.string(), z.any()).optional(),
|
|
@@ -117,7 +120,7 @@ Export schemas for client-side type reuse:
|
|
|
117
120
|
import { z } from "zod";
|
|
118
121
|
|
|
119
122
|
export const CreateUserSchema = z.object({
|
|
120
|
-
email: z.
|
|
123
|
+
email: z.email(),
|
|
121
124
|
name: z.string().min(1),
|
|
122
125
|
});
|
|
123
126
|
|
|
@@ -129,3 +132,17 @@ const body: CreateUserInput = { email: "test@example.com", name: "Test" };
|
|
|
129
132
|
```
|
|
130
133
|
|
|
131
134
|
**Note:** Nitro auto-generates response types, but NOT input types from Zod schemas.
|
|
135
|
+
|
|
136
|
+
## Zod 4 Notes (this stack pins zod ^4)
|
|
137
|
+
|
|
138
|
+
The model's prior is mostly Zod 3 — these are the idioms that changed. Get them right.
|
|
139
|
+
|
|
140
|
+
- **String formats are top-level functions, not `z.string()` methods.** Use `z.email()`, `z.url()`, `z.uuid()`, `z.ipv4()`, `z.iso.datetime()`. The chained forms (`z.string().email()`) are deprecated.
|
|
141
|
+
- **Error customization is one `error` param.** `message`, `invalid_type_error`, `required_error`, and `errorMap` are gone:
|
|
142
|
+
```typescript
|
|
143
|
+
z.string().min(5, { error: "Too short." });
|
|
144
|
+
z.string({ error: (iss) => iss.input === undefined ? "Required" : "Must be a string" });
|
|
145
|
+
```
|
|
146
|
+
- **Format errors with the built-ins**, not `zod-validation-error`: `z.prettifyError()` (human string), `z.treeifyError()` (nested, replaces deprecated `.format()`), `z.flattenError()` (replaces deprecated `.flatten()`).
|
|
147
|
+
- **`.default()` applies to the *output* type** and short-circuits parsing when input is `undefined`. For the old "run the default through the schema" behavior, use `.prefault()`.
|
|
148
|
+
- **`z.coerce.*` input type is now `unknown`** (not the output type) — fine for h3 query/body parsing, but affects schemas you consume elsewhere.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tailwind-v4",
|
|
3
|
+
"description": "Tailwind CSS v4 (CSS-first): @import/@theme/@source config, the no-tailwind.config.js model, prefers-color-scheme dark mode, and the v3->v4 traps an LLM trained on v3 falls into",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "yeedle"
|
|
7
|
+
}
|
|
8
|
+
}
|