@warlock.js/cascade 5.24.0 → 5.25.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +18 -5
  2. package/README.md +101 -101
  3. package/bin/cascade.js +0 -0
  4. package/cjs/index.cjs +201 -71
  5. package/cjs/index.cjs.map +1 -1
  6. package/esm/contracts/database-driver.contract.d.mts +22 -1
  7. package/esm/contracts/database-driver.contract.d.mts.map +1 -1
  8. package/esm/contracts/index.d.mts +1 -1
  9. package/esm/data-source/data-source-registry.d.mts +8 -6
  10. package/esm/data-source/data-source-registry.d.mts.map +1 -1
  11. package/esm/data-source/data-source-registry.mjs +7 -1
  12. package/esm/data-source/data-source-registry.mjs.map +1 -1
  13. package/esm/drivers/mongodb/mongodb-driver.d.mts +13 -0
  14. package/esm/drivers/mongodb/mongodb-driver.d.mts.map +1 -1
  15. package/esm/drivers/mongodb/mongodb-driver.mjs +71 -2
  16. package/esm/drivers/mongodb/mongodb-driver.mjs.map +1 -1
  17. package/esm/drivers/mongodb/mongodb-query-builder.d.mts.map +1 -1
  18. package/esm/drivers/mongodb/mongodb-query-builder.mjs +2 -1
  19. package/esm/drivers/mongodb/mongodb-query-builder.mjs.map +1 -1
  20. package/esm/drivers/postgres/postgres-driver.d.mts +9 -0
  21. package/esm/drivers/postgres/postgres-driver.d.mts.map +1 -1
  22. package/esm/drivers/postgres/postgres-driver.mjs +55 -7
  23. package/esm/drivers/postgres/postgres-driver.mjs.map +1 -1
  24. package/esm/drivers/postgres/postgres-query-parser.d.mts +1 -4
  25. package/esm/drivers/postgres/postgres-query-parser.d.mts.map +1 -1
  26. package/esm/drivers/postgres/postgres-query-parser.mjs +4 -5
  27. package/esm/drivers/postgres/postgres-query-parser.mjs.map +1 -1
  28. package/esm/errors/unsafe-filter.error.mjs.map +1 -1
  29. package/esm/errors/unsafe-raw-expression.error.mjs.map +1 -1
  30. package/esm/errors/unsupported-lock-for-update.error.d.mts +11 -0
  31. package/esm/errors/unsupported-lock-for-update.error.d.mts.map +1 -0
  32. package/esm/errors/unsupported-lock-for-update.error.mjs +16 -0
  33. package/esm/errors/unsupported-lock-for-update.error.mjs.map +1 -0
  34. package/esm/events/model-events.mjs.map +1 -1
  35. package/esm/index.d.mts +3 -2
  36. package/esm/index.mjs +2 -1
  37. package/esm/migration/migration-runner.mjs.map +1 -1
  38. package/esm/migration/sql-grammar.mjs.map +1 -1
  39. package/esm/model/generate-model-id.mjs +23 -0
  40. package/esm/model/generate-model-id.mjs.map +1 -0
  41. package/esm/model/methods/delete-methods.mjs.map +1 -1
  42. package/esm/model/methods/meta-methods.mjs.map +1 -1
  43. package/esm/model/methods/query-methods.mjs +25 -2
  44. package/esm/model/methods/query-methods.mjs.map +1 -1
  45. package/esm/model/methods/write-methods.mjs.map +1 -1
  46. package/esm/query-builder/query-builder.mjs +1 -1
  47. package/esm/query-builder/query-builder.mjs.map +1 -1
  48. package/esm/utils/sanitize-filter.mjs.map +1 -1
  49. package/esm/validation/plugins/database-rules-plugin.mjs.map +1 -1
  50. package/esm/writer/database-writer.d.mts +0 -33
  51. package/esm/writer/database-writer.d.mts.map +1 -1
  52. package/esm/writer/database-writer.mjs +3 -52
  53. package/esm/writer/database-writer.mjs.map +1 -1
  54. package/llms-full.txt +2901 -2864
  55. package/package.json +5 -5
  56. package/skills/aggregate-data/SKILL.md +165 -165
  57. package/skills/alter-migration/SKILL.md +531 -531
  58. package/skills/configure-delete-strategy/SKILL.md +130 -130
  59. package/skills/define-model/SKILL.md +205 -205
  60. package/skills/define-relations/SKILL.md +171 -171
  61. package/skills/manage-data-sources/SKILL.md +177 -140
  62. package/skills/manage-transactions/SKILL.md +215 -205
  63. package/skills/paginate-results/SKILL.md +122 -122
  64. package/skills/perform-atomic-ops/SKILL.md +156 -156
  65. package/skills/query-data/SKILL.md +213 -213
  66. package/skills/run-cascade-cli/SKILL.md +125 -125
  67. package/skills/search-by-vector/SKILL.md +127 -127
  68. package/skills/subscribe-to-model-events/SKILL.md +148 -148
  69. package/skills/track-changes/SKILL.md +127 -127
  70. package/skills/write-migration/SKILL.md +206 -206
package/package.json CHANGED
@@ -13,10 +13,10 @@
13
13
  "@mongez/events": "^2.2.8",
14
14
  "@mongez/reinforcements": "^4.0.2",
15
15
  "@mongez/supportive-is": "^2.1.5",
16
- "@warlock.js/context": "5.24.0",
17
- "@warlock.js/logger": "5.24.0",
18
- "@warlock.js/scheduler": "5.24.0",
19
- "@warlock.js/seal": "5.24.0",
16
+ "@warlock.js/context": "5.25.0",
17
+ "@warlock.js/logger": "5.25.0",
18
+ "@warlock.js/scheduler": "5.25.0",
19
+ "@warlock.js/seal": "5.25.0",
20
20
  "citty": "^0.2.2",
21
21
  "fast-glob": "^3.3.3"
22
22
  },
@@ -52,7 +52,7 @@
52
52
  ],
53
53
  "author": "hassanzohdy",
54
54
  "license": "MIT",
55
- "version": "5.24.0",
55
+ "version": "5.25.0",
56
56
  "main": "./cjs/index.cjs",
57
57
  "module": "./esm/index.mjs",
58
58
  "types": "./esm/index.d.mts",
@@ -1,165 +1,165 @@
1
- ---
2
- name: aggregate-data
3
- description: 'Compute aggregates over a query — scalar `.count()` / `.sum(field)` / `.avg` / `.min` / `.max`, plus grouped rollups via the two-arg `.groupBy(fields, { alias: $agg.* })`, portable date-bucketing via `.groupByDate(col, unit, aggregates?)`, the `$agg` helpers (including expression-aware `$agg.sum($expr.mul("price","quantity"))` / `$agg.sumRaw`), and `.having(alias, op, value)` on computed aggregates, plus MongoDB pipeline stages `.unwind(field, options?)` / `.addFields(fields)` / pipeline-form `join()` (`UnsupportedQueryOperationError` on Postgres). Triggers: `.unwind`, `.addFields`, `.joinRaw`, `.raw`, `$unwind`, `$lookup`, `.count`, `.sum`, `.avg`, `.min`, `.max`, `.groupBy`, `.groupByDate`, `.having`, `$agg`, `$agg.sum`, `$agg.sumRaw`, `$agg.count`, `$expr`, `$expr.mul`, `$expr.col`, `$expr.lit`; "monthly revenue report", "revenue per month", "X per category", "group by status", "sum price times quantity", "dashboard rollup"; typical import `import { Model, $agg, $expr } from "@warlock.js/cascade"`. Skip: row queries — `@warlock.js/cascade/query-data/SKILL.md`; cached aggregates — `@warlock.js/cache/use-cached-hof/SKILL.md`; competing tools raw SQL `GROUP BY`, `mongoose aggregate`, `prisma` `groupBy`.'
4
- ---
5
-
6
- # Use aggregates and groupBy
7
-
8
- Cascade's query builder isn't only for finding records — it crunches numbers too. The same calls run on MongoDB and Postgres.
9
-
10
- ## Scalar aggregates
11
-
12
- ```ts
13
- const total = await Order.count(); // count is static on the model
14
- const revenue = await Order.query().sum("total");
15
- const avgTicket = await Order.query().avg("total");
16
- const cheapest = await Order.query().min("total");
17
- const priciest = await Order.query().max("total");
18
-
19
- // Filtered
20
- const monthRevenue = await Order.query()
21
- .whereDateAfter("created_at", startOfMonth)
22
- .sum("total");
23
- ```
24
-
25
- Each returns a single number (`Promise<number>`). Only `count` is a static shortcut on the model; `sum` / `avg` / `min` / `max` (and the date helpers) are query-builder methods — reach them via `Order.query()` or chain off `Order.where(...)`. Date filters take a single date: `whereDate(field, value)` (exact day, time ignored), `whereDateAfter` / `whereDateBefore` (one-sided), `whereDateBetween(field, [a, b])` (range) — there is no 3-arg `whereDate(field, op, value)`. By Cascade convention the scalar terminators return **`0` on zero rows — not `null`** — so you can use the result directly without a null guard.
26
-
27
- ## Grouped rollups — two-arg `groupBy(fields, aggregates)`
28
-
29
- To compute aggregates *per group*, pass a second argument to `groupBy`: an object mapping output aliases to aggregate expressions. Build the expressions with the `$agg` helpers:
30
-
31
- ```ts
32
- import { $agg } from "@warlock.js/cascade";
33
-
34
- const stats = await Order.query()
35
- .groupBy("category", {
36
- total: $agg.sum("amount"),
37
- orders: $agg.count(),
38
- avg: $agg.avg("amount"),
39
- })
40
- .get();
41
- // each row: { category, total, orders, avg }
42
- ```
43
-
44
- `fields` is a string or string array (`groupBy(["status", "country"], {...})` groups by each combination). Single-arg `groupBy("category")` / `groupBy(["a","b"])` groups **without** computing aggregates.
45
-
46
- ### `$agg` helpers — six cross-driver, four MongoDB-only
47
-
48
- Cross-driver (identical call on MongoDB **and** Postgres):
49
-
50
- - `$agg.count()` · `$agg.countDistinct(field)` · `$agg.sum(input)` · `$agg.sumRaw(expression)` · `$agg.avg(field)` · `$agg.min(field)` · `$agg.max(field)`
51
- - `$agg.countDistinct(field)` counts distinct values per group: Postgres `COUNT(DISTINCT col)`; MongoDB `$addToSet` in `$group` finalized with `$size` in the renaming `$project`.
52
-
53
- ### Summing a computed expression — `$agg.sum(expr)` / `$agg.sumRaw`
54
-
55
- `$agg.sum` accepts either a bare column name (`$agg.sum("amount")`, unchanged) **or** a typed, cross-driver column expression so you can sum a computed value like `price * quantity` without dropping to raw SQL. Build the expression with the `$expr` combinators (grouped under one object, like `$agg`): `$expr.col` / `$expr.lit` / `$expr.mul` / `$expr.add` / `$expr.sub` / `$expr.div`.
56
-
57
- ```ts
58
- import { $agg, $expr } from "@warlock.js/cascade";
59
-
60
- const revenue = await OrderItem.query()
61
- .groupBy("product_id", {
62
- revenue: $agg.sum($expr.mul("price", "quantity")), // SUM(price * quantity)
63
- net: $agg.sum($expr.mul($expr.sub($expr.lit(1), "discount"), "price")),// SUM((1 - discount) * price)
64
- })
65
- .get();
66
- ```
67
-
68
- Cross-driver: Postgres emits `SUM(("price" * "quantity"))`, MongoDB emits `{ $sum: { $multiply: ["$price", "$quantity"] } }`. Column names flow through the driver's identifier-quoting path — they're never string-interpolated. The bare-string form (`$agg.sum("amount")`) produces a byte-for-byte-identical payload to before, so existing call sites are unchanged.
69
-
70
- When the typed combinators can't express what you need, reach for the raw escape hatch `$agg.sumRaw("price * quantity * (1 - discount)")` — the string is emitted verbatim (**never** build it from untrusted input). `$agg.sumRaw` is **Postgres-only**: on MongoDB it throws, since a raw SQL fragment isn't portable to a pipeline. Use the typed `$agg.sum(...)` form for cross-driver code.
71
-
72
- MongoDB-only — on Postgres these **throw at the `.groupBy()` call** with an actionable message (there is no honest single-scalar `GROUP BY` equivalent):
73
-
74
- - `$agg.distinct(field)` · `$agg.floor(field)` · `$agg.first(field)` · `$agg.last(field)`
75
-
76
- If you need those shapes on Postgres, drop to `selectRaw` / `havingRaw` with explicit SQL (e.g. `array_agg(DISTINCT …)`, a window function).
77
-
78
- ### Driver-specific escape hatch
79
-
80
- When `$agg.*` can't express it, pass a raw expression in the same slot:
81
-
82
- ```ts
83
- .groupBy("category", { total: "SUM(amount)" }) // Postgres
84
- .groupBy("category", { total: { $sum: "$amount" } }) // MongoDB
85
- ```
86
-
87
- Raw strings pass through verbatim. A MongoDB operator object passed on Postgres throws ("not portable to SQL") — keep raw expressions driver-correct.
88
-
89
- ## Date-bucketed rollups — `.groupByDate(column, unit, aggregates?)`
90
-
91
- For time-series reports ("revenue per month", "signups per week") use `groupByDate` instead of grouping on the raw timestamp — it truncates the column to a bucket and groups by the bucket, portably across drivers:
92
-
93
- ```ts
94
- import { $agg, $expr } from "@warlock.js/cascade";
95
-
96
- const monthly = await Order.query()
97
- .whereDateAfter("created_at", startOfYear)
98
- .groupByDate("created_at", "month", {
99
- revenue: $agg.sum($expr.mul("price", "quantity")),
100
- orders: $agg.count(),
101
- })
102
- .orderBy("created_at", "asc")
103
- .get();
104
- // each row: { created_at: <bucket start>, revenue, orders }
105
- ```
106
-
107
- `unit` is `"day" | "week" | "month" | "year"`. The bucketed value comes back under the **column's own name** (`created_at` above). The optional third argument is the same aggregates object as the two-arg `groupBy` (`$agg.*` helpers or driver-native raw expressions). Cross-driver: Postgres emits `date_trunc('<unit>', "column")`; MongoDB emits `{ $dateTrunc: { date: "$column", unit } }` in the `$group` `_id`. Calling `groupByDate` with no aggregates buckets and groups without computing any.
108
-
109
- ## `.having(...)` — filter groups by a computed aggregate
110
-
111
- `.where()` filters rows *before* grouping (cheap, uses indexes). `.having()` filters *after* aggregation, by the alias you defined:
112
-
113
- ```ts
114
- const big = await Order.query()
115
- .groupBy("category", { total: $agg.sum("amount") })
116
- .having("total", ">", 1000)
117
- .get();
118
- ```
119
-
120
- Works identically on both drivers. (Internally Postgres can't reference a SELECT alias in `HAVING`, so Cascade substitutes the underlying expression for you.) `having` accepts the same shapes as `where`: `having("total", 1000)`, `having("total", ">", 1000)`, `having({ total: 1000, orders: 5 })`. A `having()` on a **grouped column** (not an aggregate alias) is left as a plain column filter.
121
-
122
- ## OrderBy on aggregates
123
-
124
- ```ts
125
- await Order.query()
126
- .groupBy("category", { revenue: $agg.sum("amount") })
127
- .orderBy("revenue", "desc")
128
- .get();
129
- ```
130
-
131
- The `orderBy` reference matches the alias from the aggregates object.
132
-
133
- ## Pipeline stages — `.unwind()` / `.addFields()` / pipeline `join()` (MongoDB)
134
-
135
- ```ts
136
- // One row per tag, then filter the tags (stages run in call order)
137
- const rows = await Post.query().unwind("tags").where("tags", "news").lean().get();
138
-
139
- // Keep posts with no tags; record each tag's position
140
- await Post.query().unwind("tags", { preserveNullAndEmptyArrays: true, includeArrayIndex: "position" }).get();
141
-
142
- // Computed field, existing fields kept
143
- await Post.query().addFields({ score: { $add: ["$likes", "$shares"] } }).orderBy("score", "desc").get();
144
-
145
- // Pipeline $lookup
146
- await Post.query().join({ table: "comments", localField: "id", foreignField: "postId",
147
- alias: "approved", pipeline: [{ $match: { approved: true } }] }).lean().get();
148
- ```
149
-
150
- `unwind()` returns several rows for one stored document. Use `.lean()` when you only read them. `addFields` expressions are raw aggregation expressions: write them in code, never from request data. Computed columns in the output also work with `selectRaw({ alias: expr })` (`$project`). There is no `$facet` method yet.
151
-
152
- Raw stages: `joinRaw({ $lookup: { from, let, pipeline, as } })` (or an array of stages) is emitted verbatim in call order. `raw((pipeline) => [...pipeline, { $sample: { size: 5 } }])` gets the pipeline built so far; return a new array or mutate it and return nothing. Anything else (a SQL string, a non-stage object) throws `UnsupportedQueryOperationError`. Build these in code, never from request data.
153
-
154
- **Postgres:** `unwind()` and `addFields()` throw `UnsupportedQueryOperationError` (`operation`, `driver`) when you call them. They are never dropped without an error. Use `selectRaw` with `jsonb_array_elements()` / `unnest()`, or a related table, instead.
155
-
156
- ## Gotchas
157
-
158
- - **`where` vs `having`.** Row filters go in `.where()` (before grouping, index-friendly); aggregate filters go in `.having()`.
159
- - **Empty sets.** Scalar terminators return `0` on zero rows (above). For *grouped* reports, a group with no rows simply doesn't appear — a report over an empty range returns `[]`, not a row of zeros.
160
- - **Don't `.all()` then `array.reduce`.** Always push aggregates to the database.
161
-
162
- ## See also
163
-
164
- - [`@warlock.js/cascade/query-data/SKILL.md`](@warlock.js/cascade/query-data/SKILL.md) — the broader query vocabulary
165
- - [`@warlock.js/cache/use-cached-hof/SKILL.md`](@warlock.js/cache/use-cached-hof/SKILL.md) — caching expensive aggregate results
1
+ ---
2
+ name: aggregate-data
3
+ description: 'Compute aggregates over a query — scalar `.count()` / `.sum(field)` / `.avg` / `.min` / `.max`, plus grouped rollups via the two-arg `.groupBy(fields, { alias: $agg.* })`, portable date-bucketing via `.groupByDate(col, unit, aggregates?)`, the `$agg` helpers (including expression-aware `$agg.sum($expr.mul("price","quantity"))` / `$agg.sumRaw`), and `.having(alias, op, value)` on computed aggregates, plus MongoDB pipeline stages `.unwind(field, options?)` / `.addFields(fields)` / pipeline-form `join()` (`UnsupportedQueryOperationError` on Postgres). Triggers: `.unwind`, `.addFields`, `.joinRaw`, `.raw`, `$unwind`, `$lookup`, `.count`, `.sum`, `.avg`, `.min`, `.max`, `.groupBy`, `.groupByDate`, `.having`, `$agg`, `$agg.sum`, `$agg.sumRaw`, `$agg.count`, `$expr`, `$expr.mul`, `$expr.col`, `$expr.lit`; "monthly revenue report", "revenue per month", "X per category", "group by status", "sum price times quantity", "dashboard rollup"; typical import `import { Model, $agg, $expr } from "@warlock.js/cascade"`. Skip: row queries — `@warlock.js/cascade/query-data/SKILL.md`; cached aggregates — `@warlock.js/cache/use-cached-hof/SKILL.md`; competing tools raw SQL `GROUP BY`, `mongoose aggregate`, `prisma` `groupBy`.'
4
+ ---
5
+
6
+ # Use aggregates and groupBy
7
+
8
+ Cascade's query builder isn't only for finding records — it crunches numbers too. The same calls run on MongoDB and Postgres.
9
+
10
+ ## Scalar aggregates
11
+
12
+ ```ts
13
+ const total = await Order.count(); // count is static on the model
14
+ const revenue = await Order.query().sum("total");
15
+ const avgTicket = await Order.query().avg("total");
16
+ const cheapest = await Order.query().min("total");
17
+ const priciest = await Order.query().max("total");
18
+
19
+ // Filtered
20
+ const monthRevenue = await Order.query()
21
+ .whereDateAfter("created_at", startOfMonth)
22
+ .sum("total");
23
+ ```
24
+
25
+ Each returns a single number (`Promise<number>`). Only `count` is a static shortcut on the model; `sum` / `avg` / `min` / `max` (and the date helpers) are query-builder methods — reach them via `Order.query()` or chain off `Order.where(...)`. Date filters take a single date: `whereDate(field, value)` (exact day, time ignored), `whereDateAfter` / `whereDateBefore` (one-sided), `whereDateBetween(field, [a, b])` (range) — there is no 3-arg `whereDate(field, op, value)`. By Cascade convention the scalar terminators return **`0` on zero rows — not `null`** — so you can use the result directly without a null guard.
26
+
27
+ ## Grouped rollups — two-arg `groupBy(fields, aggregates)`
28
+
29
+ To compute aggregates *per group*, pass a second argument to `groupBy`: an object mapping output aliases to aggregate expressions. Build the expressions with the `$agg` helpers:
30
+
31
+ ```ts
32
+ import { $agg } from "@warlock.js/cascade";
33
+
34
+ const stats = await Order.query()
35
+ .groupBy("category", {
36
+ total: $agg.sum("amount"),
37
+ orders: $agg.count(),
38
+ avg: $agg.avg("amount"),
39
+ })
40
+ .get();
41
+ // each row: { category, total, orders, avg }
42
+ ```
43
+
44
+ `fields` is a string or string array (`groupBy(["status", "country"], {...})` groups by each combination). Single-arg `groupBy("category")` / `groupBy(["a","b"])` groups **without** computing aggregates.
45
+
46
+ ### `$agg` helpers — six cross-driver, four MongoDB-only
47
+
48
+ Cross-driver (identical call on MongoDB **and** Postgres):
49
+
50
+ - `$agg.count()` · `$agg.countDistinct(field)` · `$agg.sum(input)` · `$agg.sumRaw(expression)` · `$agg.avg(field)` · `$agg.min(field)` · `$agg.max(field)`
51
+ - `$agg.countDistinct(field)` counts distinct values per group: Postgres `COUNT(DISTINCT col)`; MongoDB `$addToSet` in `$group` finalized with `$size` in the renaming `$project`.
52
+
53
+ ### Summing a computed expression — `$agg.sum(expr)` / `$agg.sumRaw`
54
+
55
+ `$agg.sum` accepts either a bare column name (`$agg.sum("amount")`, unchanged) **or** a typed, cross-driver column expression so you can sum a computed value like `price * quantity` without dropping to raw SQL. Build the expression with the `$expr` combinators (grouped under one object, like `$agg`): `$expr.col` / `$expr.lit` / `$expr.mul` / `$expr.add` / `$expr.sub` / `$expr.div`.
56
+
57
+ ```ts
58
+ import { $agg, $expr } from "@warlock.js/cascade";
59
+
60
+ const revenue = await OrderItem.query()
61
+ .groupBy("product_id", {
62
+ revenue: $agg.sum($expr.mul("price", "quantity")), // SUM(price * quantity)
63
+ net: $agg.sum($expr.mul($expr.sub($expr.lit(1), "discount"), "price")),// SUM((1 - discount) * price)
64
+ })
65
+ .get();
66
+ ```
67
+
68
+ Cross-driver: Postgres emits `SUM(("price" * "quantity"))`, MongoDB emits `{ $sum: { $multiply: ["$price", "$quantity"] } }`. Column names flow through the driver's identifier-quoting path — they're never string-interpolated. The bare-string form (`$agg.sum("amount")`) produces a byte-for-byte-identical payload to before, so existing call sites are unchanged.
69
+
70
+ When the typed combinators can't express what you need, reach for the raw escape hatch `$agg.sumRaw("price * quantity * (1 - discount)")` — the string is emitted verbatim (**never** build it from untrusted input). `$agg.sumRaw` is **Postgres-only**: on MongoDB it throws, since a raw SQL fragment isn't portable to a pipeline. Use the typed `$agg.sum(...)` form for cross-driver code.
71
+
72
+ MongoDB-only — on Postgres these **throw at the `.groupBy()` call** with an actionable message (there is no honest single-scalar `GROUP BY` equivalent):
73
+
74
+ - `$agg.distinct(field)` · `$agg.floor(field)` · `$agg.first(field)` · `$agg.last(field)`
75
+
76
+ If you need those shapes on Postgres, drop to `selectRaw` / `havingRaw` with explicit SQL (e.g. `array_agg(DISTINCT …)`, a window function).
77
+
78
+ ### Driver-specific escape hatch
79
+
80
+ When `$agg.*` can't express it, pass a raw expression in the same slot:
81
+
82
+ ```ts
83
+ .groupBy("category", { total: "SUM(amount)" }) // Postgres
84
+ .groupBy("category", { total: { $sum: "$amount" } }) // MongoDB
85
+ ```
86
+
87
+ Raw strings pass through verbatim. A MongoDB operator object passed on Postgres throws ("not portable to SQL") — keep raw expressions driver-correct.
88
+
89
+ ## Date-bucketed rollups — `.groupByDate(column, unit, aggregates?)`
90
+
91
+ For time-series reports ("revenue per month", "signups per week") use `groupByDate` instead of grouping on the raw timestamp — it truncates the column to a bucket and groups by the bucket, portably across drivers:
92
+
93
+ ```ts
94
+ import { $agg, $expr } from "@warlock.js/cascade";
95
+
96
+ const monthly = await Order.query()
97
+ .whereDateAfter("created_at", startOfYear)
98
+ .groupByDate("created_at", "month", {
99
+ revenue: $agg.sum($expr.mul("price", "quantity")),
100
+ orders: $agg.count(),
101
+ })
102
+ .orderBy("created_at", "asc")
103
+ .get();
104
+ // each row: { created_at: <bucket start>, revenue, orders }
105
+ ```
106
+
107
+ `unit` is `"day" | "week" | "month" | "year"`. The bucketed value comes back under the **column's own name** (`created_at` above). The optional third argument is the same aggregates object as the two-arg `groupBy` (`$agg.*` helpers or driver-native raw expressions). Cross-driver: Postgres emits `date_trunc('<unit>', "column")`; MongoDB emits `{ $dateTrunc: { date: "$column", unit } }` in the `$group` `_id`. Calling `groupByDate` with no aggregates buckets and groups without computing any.
108
+
109
+ ## `.having(...)` — filter groups by a computed aggregate
110
+
111
+ `.where()` filters rows *before* grouping (cheap, uses indexes). `.having()` filters *after* aggregation, by the alias you defined:
112
+
113
+ ```ts
114
+ const big = await Order.query()
115
+ .groupBy("category", { total: $agg.sum("amount") })
116
+ .having("total", ">", 1000)
117
+ .get();
118
+ ```
119
+
120
+ Works identically on both drivers. (Internally Postgres can't reference a SELECT alias in `HAVING`, so Cascade substitutes the underlying expression for you.) `having` accepts the same shapes as `where`: `having("total", 1000)`, `having("total", ">", 1000)`, `having({ total: 1000, orders: 5 })`. A `having()` on a **grouped column** (not an aggregate alias) is left as a plain column filter.
121
+
122
+ ## OrderBy on aggregates
123
+
124
+ ```ts
125
+ await Order.query()
126
+ .groupBy("category", { revenue: $agg.sum("amount") })
127
+ .orderBy("revenue", "desc")
128
+ .get();
129
+ ```
130
+
131
+ The `orderBy` reference matches the alias from the aggregates object.
132
+
133
+ ## Pipeline stages — `.unwind()` / `.addFields()` / pipeline `join()` (MongoDB)
134
+
135
+ ```ts
136
+ // One row per tag, then filter the tags (stages run in call order)
137
+ const rows = await Post.query().unwind("tags").where("tags", "news").lean().get();
138
+
139
+ // Keep posts with no tags; record each tag's position
140
+ await Post.query().unwind("tags", { preserveNullAndEmptyArrays: true, includeArrayIndex: "position" }).get();
141
+
142
+ // Computed field, existing fields kept
143
+ await Post.query().addFields({ score: { $add: ["$likes", "$shares"] } }).orderBy("score", "desc").get();
144
+
145
+ // Pipeline $lookup
146
+ await Post.query().join({ table: "comments", localField: "id", foreignField: "postId",
147
+ alias: "approved", pipeline: [{ $match: { approved: true } }] }).lean().get();
148
+ ```
149
+
150
+ `unwind()` returns several rows for one stored document. Use `.lean()` when you only read them. `addFields` expressions are raw aggregation expressions: write them in code, never from request data. Computed columns in the output also work with `selectRaw({ alias: expr })` (`$project`). There is no `$facet` method yet.
151
+
152
+ Raw stages: `joinRaw({ $lookup: { from, let, pipeline, as } })` (or an array of stages) is emitted verbatim in call order. `raw((pipeline) => [...pipeline, { $sample: { size: 5 } }])` gets the pipeline built so far; return a new array or mutate it and return nothing. Anything else (a SQL string, a non-stage object) throws `UnsupportedQueryOperationError`. Build these in code, never from request data.
153
+
154
+ **Postgres:** `unwind()` and `addFields()` throw `UnsupportedQueryOperationError` (`operation`, `driver`) when you call them. They are never dropped without an error. Use `selectRaw` with `jsonb_array_elements()` / `unnest()`, or a related table, instead.
155
+
156
+ ## Gotchas
157
+
158
+ - **`where` vs `having`.** Row filters go in `.where()` (before grouping, index-friendly); aggregate filters go in `.having()`.
159
+ - **Empty sets.** Scalar terminators return `0` on zero rows (above). For *grouped* reports, a group with no rows simply doesn't appear — a report over an empty range returns `[]`, not a row of zeros.
160
+ - **Don't `.all()` then `array.reduce`.** Always push aggregates to the database.
161
+
162
+ ## See also
163
+
164
+ - [`@warlock.js/cascade/query-data/SKILL.md`](@warlock.js/cascade/query-data/SKILL.md) — the broader query vocabulary
165
+ - [`@warlock.js/cache/use-cached-hof/SKILL.md`](@warlock.js/cache/use-cached-hof/SKILL.md) — caching expensive aggregate results