@warlock.js/cascade 5.19.0 → 5.20.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 (39) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/cjs/index.cjs +78 -7
  3. package/cjs/index.cjs.map +1 -1
  4. package/esm/context/database-transaction-context.d.mts +10 -0
  5. package/esm/context/database-transaction-context.d.mts.map +1 -1
  6. package/esm/context/database-transaction-context.mjs +17 -0
  7. package/esm/context/database-transaction-context.mjs.map +1 -1
  8. package/esm/drivers/mongodb/mongodb-driver.d.mts.map +1 -1
  9. package/esm/drivers/mongodb/mongodb-driver.mjs +13 -5
  10. package/esm/drivers/mongodb/mongodb-driver.mjs.map +1 -1
  11. package/esm/drivers/postgres/postgres-driver.d.mts.map +1 -1
  12. package/esm/drivers/postgres/postgres-driver.mjs +8 -3
  13. package/esm/drivers/postgres/postgres-driver.mjs.map +1 -1
  14. package/esm/index.d.mts +2 -1
  15. package/esm/index.mjs +2 -1
  16. package/esm/migration/migration-runner.mjs +1 -1
  17. package/esm/transactions/after-commit.d.mts +24 -0
  18. package/esm/transactions/after-commit.d.mts.map +1 -0
  19. package/esm/transactions/after-commit.mjs +47 -0
  20. package/esm/transactions/after-commit.mjs.map +1 -0
  21. package/llms-full.txt +2783 -2766
  22. package/llms.txt +15 -15
  23. package/package.json +6 -6
  24. package/skills/aggregate-data/SKILL.md +165 -165
  25. package/skills/alter-migration/SKILL.md +531 -531
  26. package/skills/configure-delete-strategy/SKILL.md +130 -130
  27. package/skills/define-model/SKILL.md +205 -205
  28. package/skills/define-relations/SKILL.md +171 -171
  29. package/skills/index.md +23 -0
  30. package/skills/manage-data-sources/SKILL.md +140 -140
  31. package/skills/manage-transactions/SKILL.md +208 -191
  32. package/skills/paginate-results/SKILL.md +122 -122
  33. package/skills/perform-atomic-ops/SKILL.md +156 -156
  34. package/skills/query-data/SKILL.md +213 -213
  35. package/skills/run-cascade-cli/SKILL.md +125 -125
  36. package/skills/search-by-vector/SKILL.md +127 -127
  37. package/skills/subscribe-to-model-events/SKILL.md +148 -148
  38. package/skills/track-changes/SKILL.md +127 -127
  39. package/skills/write-migration/SKILL.md +206 -206
package/llms.txt CHANGED
@@ -6,19 +6,19 @@
6
6
 
7
7
  ## Skills
8
8
 
9
- - [aggregate-data](@warlock.js/cascade/aggregate-data/SKILL.md): Use aggregates and groupBy in @warlock.js/cascade; use when you need to aggregate data.
10
- - [alter-migration](@warlock.js/cascade/alter-migration/SKILL.md): Alter a migration in @warlock.js/cascade; use when you need to alter migration.
9
+ - [aggregate-data](@warlock.js/cascade/aggregate-data/SKILL.md): (no description)
10
+ - [alter-migration](@warlock.js/cascade/alter-migration/SKILL.md): (no description)
11
11
  - [cascade-basics](@warlock.js/cascade/cascade-basics/SKILL.md): Start here for @warlock.js/cascade models, data sources, migrations, and query workflows.
12
- - [configure-delete-strategy](@warlock.js/cascade/configure-delete-strategy/SKILL.md): Use delete strategies in @warlock.js/cascade; use when you need to configure delete strategy.
13
- - [define-model](@warlock.js/cascade/define-model/SKILL.md): Define a model in @warlock.js/cascade; use when you need to define model.
14
- - [define-relations](@warlock.js/cascade/define-relations/SKILL.md): Define and query relations in @warlock.js/cascade; use when you need to define relations.
15
- - [manage-data-sources](@warlock.js/cascade/manage-data-sources/SKILL.md): Manage data sources in @warlock.js/cascade; use when you need to manage data sources.
16
- - [manage-transactions](@warlock.js/cascade/manage-transactions/SKILL.md): Use transactions in @warlock.js/cascade; use when you need to manage transactions.
17
- - [paginate-results](@warlock.js/cascade/paginate-results/SKILL.md): Paginate results in @warlock.js/cascade; use when you need to paginate results.
18
- - [perform-atomic-ops](@warlock.js/cascade/perform-atomic-ops/SKILL.md): Use atomic operations in @warlock.js/cascade; use when you need to perform atomic ops.
19
- - [query-data](@warlock.js/cascade/query-data/SKILL.md): Query data in @warlock.js/cascade; use when you need to query data.
20
- - [run-cascade-cli](@warlock.js/cascade/run-cascade-cli/SKILL.md): Run the cascade CLI / Operations API in @warlock.js/cascade; use when you need to run cascade cli.
21
- - [search-by-vector](@warlock.js/cascade/search-by-vector/SKILL.md): Use vector search in @warlock.js/cascade; use when you need to search by vector.
22
- - [subscribe-to-model-events](@warlock.js/cascade/subscribe-to-model-events/SKILL.md): Use model events in @warlock.js/cascade; use when you need to subscribe to model events.
23
- - [track-changes](@warlock.js/cascade/track-changes/SKILL.md): Track changes in @warlock.js/cascade; use when you need to track changes.
24
- - [write-migration](@warlock.js/cascade/write-migration/SKILL.md): Write a migration in @warlock.js/cascade; use when you need to write migration.
12
+ - [configure-delete-strategy](@warlock.js/cascade/configure-delete-strategy/SKILL.md): (no description)
13
+ - [define-model](@warlock.js/cascade/define-model/SKILL.md): (no description)
14
+ - [define-relations](@warlock.js/cascade/define-relations/SKILL.md): (no description)
15
+ - [manage-data-sources](@warlock.js/cascade/manage-data-sources/SKILL.md): (no description)
16
+ - [manage-transactions](@warlock.js/cascade/manage-transactions/SKILL.md): (no description)
17
+ - [paginate-results](@warlock.js/cascade/paginate-results/SKILL.md): (no description)
18
+ - [perform-atomic-ops](@warlock.js/cascade/perform-atomic-ops/SKILL.md): (no description)
19
+ - [query-data](@warlock.js/cascade/query-data/SKILL.md): (no description)
20
+ - [run-cascade-cli](@warlock.js/cascade/run-cascade-cli/SKILL.md): (no description)
21
+ - [search-by-vector](@warlock.js/cascade/search-by-vector/SKILL.md): (no description)
22
+ - [subscribe-to-model-events](@warlock.js/cascade/subscribe-to-model-events/SKILL.md): (no description)
23
+ - [track-changes](@warlock.js/cascade/track-changes/SKILL.md): (no description)
24
+ - [write-migration](@warlock.js/cascade/write-migration/SKILL.md): (no description)
package/package.json CHANGED
@@ -11,11 +11,11 @@
11
11
  "@mongez/copper": "^2.1.2",
12
12
  "@mongez/dotenv": "^1.3.2",
13
13
  "@mongez/events": "^2.2.7",
14
- "@mongez/reinforcements": "^4.0.1",
15
- "@mongez/supportive-is": "^2.1.4",
16
- "@warlock.js/context": "5.19.0",
17
- "@warlock.js/logger": "5.19.0",
18
- "@warlock.js/seal": "5.19.0",
14
+ "@mongez/reinforcements": "^4.0.2",
15
+ "@mongez/supportive-is": "^2.1.5",
16
+ "@warlock.js/context": "5.20.0",
17
+ "@warlock.js/logger": "5.20.0",
18
+ "@warlock.js/seal": "5.20.0",
19
19
  "citty": "^0.2.2",
20
20
  "fast-glob": "^3.3.3"
21
21
  },
@@ -51,7 +51,7 @@
51
51
  ],
52
52
  "author": "hassanzohdy",
53
53
  "license": "MIT",
54
- "version": "5.19.0",
54
+ "version": "5.20.0",
55
55
  "main": "./cjs/index.cjs",
56
56
  "module": "./esm/index.mjs",
57
57
  "types": "./esm/index.d.mts",
@@ -1,165 +1,165 @@
1
- ---
2
- name: aggregate-data
3
- description: "Use aggregates and groupBy in @warlock.js/cascade; use when you need to aggregate data."
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