@warlock.js/cascade 4.4.0 → 4.6.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 (77) hide show
  1. package/CHANGELOG.md +29 -3
  2. package/cjs/index.cjs +933 -90
  3. package/cjs/index.cjs.map +1 -1
  4. package/esm/contracts/database-driver.contract.d.mts +27 -4
  5. package/esm/contracts/database-driver.contract.d.mts.map +1 -1
  6. package/esm/contracts/database-id-generator.contract.d.mts +38 -0
  7. package/esm/contracts/database-id-generator.contract.d.mts.map +1 -1
  8. package/esm/contracts/index.d.mts +1 -1
  9. package/esm/contracts/query-builder.contract.d.mts +27 -0
  10. package/esm/contracts/query-builder.contract.d.mts.map +1 -1
  11. package/esm/data-source/data-source.d.mts +21 -1
  12. package/esm/data-source/data-source.d.mts.map +1 -1
  13. package/esm/data-source/data-source.mjs +22 -0
  14. package/esm/data-source/data-source.mjs.map +1 -1
  15. package/esm/drivers/mongodb/mongodb-driver.d.mts +2 -2
  16. package/esm/drivers/mongodb/mongodb-driver.d.mts.map +1 -1
  17. package/esm/drivers/mongodb/mongodb-driver.mjs.map +1 -1
  18. package/esm/drivers/mongodb/mongodb-id-generator.d.mts +98 -48
  19. package/esm/drivers/mongodb/mongodb-id-generator.d.mts.map +1 -1
  20. package/esm/drivers/mongodb/mongodb-id-generator.mjs +153 -59
  21. package/esm/drivers/mongodb/mongodb-id-generator.mjs.map +1 -1
  22. package/esm/drivers/mongodb/mongodb-query-builder.d.mts +25 -0
  23. package/esm/drivers/mongodb/mongodb-query-builder.d.mts.map +1 -1
  24. package/esm/drivers/mongodb/mongodb-query-builder.mjs +32 -0
  25. package/esm/drivers/mongodb/mongodb-query-builder.mjs.map +1 -1
  26. package/esm/drivers/mongodb/mongodb-query-parser.d.mts +36 -0
  27. package/esm/drivers/mongodb/mongodb-query-parser.d.mts.map +1 -1
  28. package/esm/drivers/mongodb/mongodb-query-parser.mjs +80 -1
  29. package/esm/drivers/mongodb/mongodb-query-parser.mjs.map +1 -1
  30. package/esm/drivers/postgres/postgres-dialect.d.mts +32 -4
  31. package/esm/drivers/postgres/postgres-dialect.d.mts.map +1 -1
  32. package/esm/drivers/postgres/postgres-dialect.mjs +57 -4
  33. package/esm/drivers/postgres/postgres-dialect.mjs.map +1 -1
  34. package/esm/drivers/postgres/postgres-driver.d.mts +42 -0
  35. package/esm/drivers/postgres/postgres-driver.d.mts.map +1 -1
  36. package/esm/drivers/postgres/postgres-driver.mjs +55 -6
  37. package/esm/drivers/postgres/postgres-driver.mjs.map +1 -1
  38. package/esm/drivers/postgres/postgres-query-builder.d.mts +32 -0
  39. package/esm/drivers/postgres/postgres-query-builder.d.mts.map +1 -1
  40. package/esm/drivers/postgres/postgres-query-builder.mjs +47 -1
  41. package/esm/drivers/postgres/postgres-query-builder.mjs.map +1 -1
  42. package/esm/drivers/postgres/postgres-query-parser.d.mts +13 -2
  43. package/esm/drivers/postgres/postgres-query-parser.d.mts.map +1 -1
  44. package/esm/drivers/postgres/postgres-query-parser.mjs +21 -4
  45. package/esm/drivers/postgres/postgres-query-parser.mjs.map +1 -1
  46. package/esm/drivers/postgres/types.d.mts +15 -0
  47. package/esm/drivers/postgres/types.d.mts.map +1 -1
  48. package/esm/drivers/sql/sql-dialect.contract.d.mts +20 -0
  49. package/esm/drivers/sql/sql-dialect.contract.d.mts.map +1 -1
  50. package/esm/expressions/aggregate-expressions.d.mts +71 -34
  51. package/esm/expressions/aggregate-expressions.d.mts.map +1 -1
  52. package/esm/expressions/aggregate-expressions.mjs +80 -7
  53. package/esm/expressions/aggregate-expressions.mjs.map +1 -1
  54. package/esm/expressions/column-expressions.d.mts +193 -0
  55. package/esm/expressions/column-expressions.d.mts.map +1 -0
  56. package/esm/expressions/column-expressions.mjs +152 -0
  57. package/esm/expressions/column-expressions.mjs.map +1 -0
  58. package/esm/index.d.mts +3 -2
  59. package/esm/index.mjs +2 -1
  60. package/esm/model/methods/write-methods.d.mts +29 -0
  61. package/esm/model/methods/write-methods.d.mts.map +1 -0
  62. package/esm/model/methods/write-methods.mjs +164 -2
  63. package/esm/model/methods/write-methods.mjs.map +1 -1
  64. package/esm/model/model.d.mts +64 -3
  65. package/esm/model/model.d.mts.map +1 -1
  66. package/esm/model/model.mjs +65 -3
  67. package/esm/model/model.mjs.map +1 -1
  68. package/esm/writer/database-writer.d.mts.map +1 -1
  69. package/esm/writer/database-writer.mjs +4 -3
  70. package/esm/writer/database-writer.mjs.map +1 -1
  71. package/llms-full.txt +106 -7
  72. package/llms.txt +3 -3
  73. package/package.json +4 -4
  74. package/skills/README.md +3 -3
  75. package/skills/aggregate-data/SKILL.md +43 -3
  76. package/skills/manage-transactions/SKILL.md +40 -1
  77. package/skills/perform-atomic-ops/SKILL.md +23 -3
package/llms-full.txt CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  ---
10
10
  name: aggregate-data
11
- description: 'Compute aggregates over a query — scalar `.count()` / `.sum(field)` / `.avg` / `.min` / `.max`, plus grouped rollups via the two-arg `.groupBy(fields, { alias: $agg.* })` with the `$agg` helpers and `.having(alias, op, value)` on computed aggregates. Triggers: `.count`, `.sum`, `.avg`, `.min`, `.max`, `.groupBy`, `.having`, `$agg`, `$agg.sum`, `$agg.count`; "monthly revenue report", "X per category", "group by status", "dashboard rollup"; typical import `import { Model, $agg } 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`.'
11
+ 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. Triggers: `.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`.'
12
12
  ---
13
13
 
14
14
  # Use aggregates and groupBy
@@ -51,11 +51,31 @@ const stats = await Order.query()
51
51
 
52
52
  `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.
53
53
 
54
- ### `$agg` helpers — five cross-driver, four MongoDB-only
54
+ ### `$agg` helpers — six cross-driver, four MongoDB-only
55
55
 
56
56
  Cross-driver (identical call on MongoDB **and** Postgres):
57
57
 
58
- - `$agg.count()` · `$agg.sum(field)` · `$agg.avg(field)` · `$agg.min(field)` · `$agg.max(field)`
58
+ - `$agg.count()` · `$agg.countDistinct(field)` · `$agg.sum(input)` · `$agg.sumRaw(expression)` · `$agg.avg(field)` · `$agg.min(field)` · `$agg.max(field)`
59
+ - `$agg.countDistinct(field)` counts distinct values per group: Postgres `COUNT(DISTINCT col)`; MongoDB `$addToSet` in `$group` finalized with `$size` in the renaming `$project`.
60
+
61
+ ### Summing a computed expression — `$agg.sum(expr)` / `$agg.sumRaw`
62
+
63
+ `$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`.
64
+
65
+ ```ts
66
+ import { $agg, $expr } from "@warlock.js/cascade";
67
+
68
+ const revenue = await OrderItem.query()
69
+ .groupBy("product_id", {
70
+ revenue: $agg.sum($expr.mul("price", "quantity")), // SUM(price * quantity)
71
+ net: $agg.sum($expr.mul($expr.sub($expr.lit(1), "discount"), "price")),// SUM((1 - discount) * price)
72
+ })
73
+ .get();
74
+ ```
75
+
76
+ 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.
77
+
78
+ 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.
59
79
 
60
80
  MongoDB-only — on Postgres these **throw at the `.groupBy()` call** with an actionable message (there is no honest single-scalar `GROUP BY` equivalent):
61
81
 
@@ -74,6 +94,26 @@ When `$agg.*` can't express it, pass a raw expression in the same slot:
74
94
 
75
95
  Raw strings pass through verbatim. A MongoDB operator object passed on Postgres throws ("not portable to SQL") — keep raw expressions driver-correct.
76
96
 
97
+ ## Date-bucketed rollups — `.groupByDate(column, unit, aggregates?)`
98
+
99
+ 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:
100
+
101
+ ```ts
102
+ import { $agg, $expr } from "@warlock.js/cascade";
103
+
104
+ const monthly = await Order.query()
105
+ .whereDateAfter("created_at", startOfYear)
106
+ .groupByDate("created_at", "month", {
107
+ revenue: $agg.sum($expr.mul("price", "quantity")),
108
+ orders: $agg.count(),
109
+ })
110
+ .orderBy("created_at", "asc")
111
+ .get();
112
+ // each row: { created_at: <bucket start>, revenue, orders }
113
+ ```
114
+
115
+ `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.
116
+
77
117
  ## `.having(...)` — filter groups by a computed aggregate
78
118
 
79
119
  `.where()` filters rows *before* grouping (cheap, uses indexes). `.having()` filters *after* aggregation, by the alias you defined:
@@ -1392,7 +1432,7 @@ There is no `has(name)` / `list()` / `setDefault()` — guard with a `try/catch`
1392
1432
 
1393
1433
  ---
1394
1434
  name: manage-transactions
1395
- description: 'Wrap multi-statement work in `transaction(async () => {...})` — rollback on throw, commit on resolve, optional `isolation` level (Postgres), per-`dataSource` scope. Postgres native; MongoDB requires replica set. Triggers: `transaction`, `isolation`, `SERIALIZABLE`, `READ COMMITTED`, nested savepoints; "wrap two writes atomically", "transfer balance between accounts", "rollback on error", "MongoDB replica set transactions"; typical import `import { transaction } from "@warlock.js/cascade"`. Skip: single-row atomic ops without a transaction — `@warlock.js/cascade/perform-atomic-ops/SKILL.md`; per-source scope — `@warlock.js/cascade/manage-data-sources/SKILL.md`; competing patterns `mongoose.startSession`, `pg` `BEGIN` manually, `prisma.$transaction`, `typeorm` `QueryRunner`.'
1435
+ description: 'Wrap multi-statement work in `transaction(async () => {...})` — rollback on throw, commit on resolve, optional `isolation` level (Postgres), per-`dataSource` scope. Also the home for transaction-aware raw SQL (`Model.raw` / `DataSource.raw` → `RawQueryResult`) and the Postgres `nativeArrayColumns` connection option. Postgres native; MongoDB requires replica set. Triggers: `transaction`, `isolation`, `SERIALIZABLE`, `READ COMMITTED`, nested savepoints, `Model.raw`, `DataSource.raw`, raw SQL, `RawQueryResult`, `nativeArrayColumns`, `JSONB[]`, `TEXT[]`; "wrap two writes atomically", "transfer balance between accounts", "rollback on error", "MongoDB replica set transactions", "run raw SQL", "native array column"; typical import `import { transaction } from "@warlock.js/cascade"`. Skip: single-row atomic ops without a transaction — `@warlock.js/cascade/perform-atomic-ops/SKILL.md`; per-source scope — `@warlock.js/cascade/manage-data-sources/SKILL.md`; competing patterns `mongoose.startSession`, `pg` `BEGIN` manually, `prisma.$transaction`, `typeorm` `QueryRunner`.'
1396
1436
  ---
1397
1437
 
1398
1438
  # Use transactions
@@ -1493,6 +1533,45 @@ On `SERIALIZABLE`, Postgres may abort with a serialization failure when concurre
1493
1533
 
1494
1534
  Once the callback returns, the transaction is committed. Subsequent calls — including reads — see the committed state. Don't try to "share" a model instance between inside-transaction and outside contexts; reload outside if you need fresh state.
1495
1535
 
1536
+ ## Raw SQL — `Model.raw` / `DataSource.raw` (transaction-aware)
1537
+
1538
+ When the query builder can't express something, drop to raw SQL. Both helpers are **transaction-aware**: called inside an active `transaction()` scope they auto-join that transaction's client/session, otherwise they run on the pool.
1539
+
1540
+ ```ts
1541
+ // Off a model — uses the model's driver
1542
+ const { rows, rowCount } = await User.raw<{ id: number; total: number }>(
1543
+ "SELECT id, COUNT(*) AS total FROM orders WHERE user_id = $1 GROUP BY id",
1544
+ [userId],
1545
+ );
1546
+
1547
+ // Inside a transaction — auto-joins the active scope
1548
+ await transaction(async () => {
1549
+ await User.raw("UPDATE users SET active = true WHERE id = $1", [id]);
1550
+ });
1551
+
1552
+ // Off a data source directly
1553
+ const { rows: counts } = await dataSource.raw<{ count: number }>(
1554
+ "SELECT COUNT(*)::int AS count FROM users",
1555
+ );
1556
+ ```
1557
+
1558
+ `Model.raw<T>(sql, params?)` and `DataSource.raw<T>(sql, params?)` both return `Promise<RawQueryResult<T>>` — `{ rows: T[]; rowCount: number }`, **not** hydrated model instances. MongoDB drivers **throw**: there is no raw SQL on Mongo.
1559
+
1560
+ ### `nativeArrayColumns` (Postgres connection option)
1561
+
1562
+ The Postgres serializer can't see the table schema, so by default it `JSON.stringify`s every non-vector array (and plain object) to bind it as JSON text — the correct form for a `json` / `jsonb` column. For a genuine **native-array** column (`JSONB[]` / `TEXT[]` / `INTEGER[]`, e.g. from `arrayJson()` / `arrayText()`) that's wrong: node-pg must receive the raw JS array so it emits a `{...}` array literal. List those columns in the connection's `nativeArrayColumns` to opt them out of JSON-text encoding:
1563
+
1564
+ ```ts
1565
+ connectToDatabase({
1566
+ name: "default",
1567
+ driver: "postgres",
1568
+ // ...
1569
+ nativeArrayColumns: ["tags", "category_ids"], // genuine TEXT[] / INTEGER[] columns
1570
+ });
1571
+ ```
1572
+
1573
+ The pgvector all-number array form is preserved automatically — you only need this for non-vector native arrays.
1574
+
1496
1575
  ## Side effects after commit — the outbox pattern
1497
1576
 
1498
1577
  For "side effects must only happen if the transaction succeeded" (publish to a queue, send an email, write to a search index), don't run them inside the transaction. Use the outbox pattern: write a row to an outbox table inside the transaction, dispatch from the outbox in a separate worker after commit.
@@ -1640,7 +1719,7 @@ The total count requires an extra query. On very large filtered tables, this can
1640
1719
 
1641
1720
  ---
1642
1721
  name: perform-atomic-ops
1643
- description: 'Avoid races on concurrent writes — `Model.increase(filter, field, n)` / `Model.decrease` for atomic counters, `Model.atomic(filter, ops)` for arbitrary mutations (`$set` / `$inc` / `$push` / `$pull`), `Model.createMany` / `Model.findAndUpdate` / `Model.delete` for bulk. Triggers: `Model.increase`, `Model.decrease`, `Model.atomic`, `Model.createMany`, `Model.findAndUpdate`, `Model.delete`, `$inc`, `$set`; "increment counter under concurrency", "bulk insert without N+1", "atomic update without loading"; typical import `import { Model } from "@warlock.js/cascade"`. Skip: multi-row atomicity — `@warlock.js/cascade/manage-transactions/SKILL.md`; competing patterns `mongoose findOneAndUpdate`, `pg` `UPDATE ... SET x = x + 1`.'
1722
+ description: 'Avoid races on concurrent writes — `Model.increase(filter, field, n)` / `Model.decrease` for atomic counters, `Model.atomic(filter, ops)` for arbitrary mutations (`$set` / `$inc` / `$push` / `$pull`), `Model.createMany` / `Model.findAndUpdate` / `Model.delete` for bulk. Triggers: `Model.increase`, `Model.decrease`, `Model.atomic`, `Model.createMany`, `createMany bulk`, `batchSize`, `Model.findAndUpdate`, `Model.delete`, `$inc`, `$set`; "increment counter under concurrency", "bulk insert without N+1", "fast bulk insert", "insert thousands of rows", "atomic update without loading"; typical import `import { Model } from "@warlock.js/cascade"`. Skip: multi-row atomicity — `@warlock.js/cascade/manage-transactions/SKILL.md`; competing patterns `mongoose findOneAndUpdate`, `pg` `UPDATE ... SET x = x + 1`.'
1644
1723
  ---
1645
1724
 
1646
1725
  # Use atomic operations
@@ -1678,7 +1757,25 @@ const created = await OrderItem.createMany([
1678
1757
  // created: OrderItem[]
1679
1758
  ```
1680
1759
 
1681
- `Model.createMany(rows)` → `Promise<TModel[]>`. Validation runs per row; wrap in a transaction if you need strict all-or-nothing semantics.
1760
+ `Model.createMany(rows, options?)` → `Promise<TModel[]>`. `options` is `{ batchSize?: number; bulk?: boolean }`. Validation runs per row; wrap in a transaction if you need strict all-or-nothing semantics.
1761
+
1762
+ ### `batchSize` — chunking (both paths)
1763
+
1764
+ A huge array is processed in sequential chunks so it can't flood the driver. `batchSize` sets the chunk size; the default is **500** (`DEFAULT_CREATE_MANY_BATCH_SIZE`). Each chunk completes before the next starts:
1765
+
1766
+ ```ts
1767
+ await OrderItem.createMany(millionRows, { batchSize: 1000 });
1768
+ ```
1769
+
1770
+ ### `bulk: true` — native multi-row insert (10–100× faster)
1771
+
1772
+ The default path persists each row through `save()`, so model hooks, lifecycle events (`saving` / `creating` / `created` / `saved`), casts, and generated ids are all preserved. Pass `bulk: true` to instead route each chunk to the driver's native multi-row `insertMany` for 10–100× throughput on large arrays:
1773
+
1774
+ ```ts
1775
+ const rows = await OrderItem.createMany(millionRows, { bulk: true, batchSize: 1000 });
1776
+ ```
1777
+
1778
+ **Tradeoff.** The bulk path SKIPS the per-row lifecycle — no `saving` / `creating` / `created` / `saved` events, no instance hooks, no sync. What it KEEPS: rows are still prepped through the writer pipeline, so validation, casts, timestamps, defaults, and id-generation still run and the persisted columns match the default path; driver-returned values (generated `_id`, timestamps, SQL `RETURNING *`) are merged back onto the returned models. Reach for `bulk: true` when inserting large batches where per-row events don't matter (seeders, imports, denormalization); stay on the default path when you need the hooks/events.
1682
1779
 
1683
1780
  ## Bulk update — `Model.findAndUpdate(filter, operations)`
1684
1781
 
@@ -1719,6 +1816,7 @@ for (const user of targets) {
1719
1816
  | Increment a counter | `Model.increase(filter, field, n)` |
1720
1817
  | Atomically change multiple fields on one record | `Model.atomic(filter, ops)` |
1721
1818
  | Insert N records | `Model.createMany(rows)` |
1819
+ | Insert a large batch fast (no per-row events) | `Model.createMany(rows, { bulk: true, batchSize })` |
1722
1820
  | Update many rows with operators | `Model.findAndUpdate(filter, { $set: {...} })` |
1723
1821
  | Update one record by id | `Model.update(id, data)` |
1724
1822
  | Delete many rows (raw) | `Model.delete(filter)` |
@@ -1730,7 +1828,8 @@ for (const user of targets) {
1730
1828
  - Don't `const post = await Post.find(id); post.set("views", post.get<number>("views") + 1); await post.save();` for a counter. That's a lost-update race under concurrency. Use `Post.increase(filter, "views", 1)`.
1731
1829
  - Don't reach for `insertMany` / `updateMany` / `deleteMany` — those names don't exist on the model. Use `createMany` / `findAndUpdate` / `delete`.
1732
1830
  - Don't expect `findAndUpdate` / `delete` to fire per-row `saved` / `deleted` events or honor the delete strategy. They don't. Iterate if you need that.
1733
- - Don't bulk-insert a million rows in one `createMany` call chunk it. Most drivers cap effectively at a few thousand per round-trip.
1831
+ - Don't hand-roll chunking around `createMany` — it already chunks by `batchSize` (default 500). Tune `batchSize` instead of slicing the array yourself.
1832
+ - Don't assume `bulk: true` fires per-row `saving` / `created` / `saved` events or runs instance hooks — it doesn't. Casts/timestamps/defaults/ids still apply, but if you need the lifecycle, use the default path (or iterate with `.save()`).
1734
1833
 
1735
1834
  ## See also
1736
1835
 
package/llms.txt CHANGED
@@ -6,16 +6,16 @@
6
6
 
7
7
  ## Skills
8
8
 
9
- - [aggregate-data](@warlock.js/cascade/aggregate-data/SKILL.md): Compute aggregates over a query — scalar `.count()` / `.sum(field)` / `.avg` / `.min` / `.max`, plus grouped rollups via the two-arg `.groupBy(fields, { alias: $agg.* })` with the `$agg` helpers and `.having(alias, op, value)` on computed aggregates. Triggers: `.count`, `.sum`, `.avg`, `.min`, `.max`, `.groupBy`, `.having`, `$agg`, `$agg.sum`, `$agg.count`; "monthly revenue report", "X per category", "group by status", "dashboard rollup"; typical import `import { Model, $agg } 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`.
9
+ - [aggregate-data](@warlock.js/cascade/aggregate-data/SKILL.md): 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. Triggers: `.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`.
10
10
  - [alter-migration](@warlock.js/cascade/alter-migration/SKILL.md): Evolve an existing table with `Migration.alter(Model, schema, options?)` — add/drop/rename/modify columns; add/drop regular, unique, expression, full-text, geo, vector, and TTL indexes; add/drop foreign keys and CHECK constraints; write rollbacks with class-form methods in `down()`. Triggers: "alter a table", "add a column to existing table", "drop a column", "rename a column", "add an index", "drop a unique constraint", "change a column type", `Migration.alter`, `dropUnique`, `addIndex`, `addForeign`. Skip: creating a brand-new table — `@warlock.js/cascade/write-migration/SKILL.md`.
11
11
  - [cascade-basics](@warlock.js/cascade/cascade-basics/SKILL.md): Start with @warlock.js/cascade ORM — model-first for MongoDB and Postgres, one schema (seal) does triple duty (type / validator / DB shape), model is the query entry point. Triggers: `Model`, `RegisterModel`, `connectToDatabase`, `Infer`, `v.object`; "which cascade skill do I need", "set up the ORM", "define my first model", "model-first ORM"; typical import `import { Model, RegisterModel } from "@warlock.js/cascade"`. Skip: schema vocabulary — `@warlock.js/seal/seal-basics/SKILL.md`; competing libs `mongoose`, `prisma`, `typeorm`, `drizzle`, `sequelize`, `mongodb` driver, `knex`.
12
12
  - [configure-delete-strategy](@warlock.js/cascade/configure-delete-strategy/SKILL.md): Pick the delete behavior — `permanent` (hard delete), `soft` (set `deletedAt`, keep the row), `trash` (move to a separate table). Configure via `static deleteStrategy` or `.destroy({ strategy })`; restore via static `Model.restore(id)` / `Model.restoreAll()`. Triggers: `static deleteStrategy`, `.destroy`, `Model.restore`, `Model.restoreAll`, `deletedAtColumn`, `trashTable`; "soft delete users", "restore a deleted record", "GDPR hard delete"; typical import `import { Model } from "@warlock.js/cascade"`. Skip: lifecycle events — `@warlock.js/cascade/subscribe-to-model-events/SKILL.md`; competing libs `mongoose-delete`, `typeorm softRemove`, `sequelize` paranoid.
13
13
  - [define-model](@warlock.js/cascade/define-model/SKILL.md): Define a Cascade model — `@RegisterModel()`, class extends `Model<TSchema>`, `static table`, `static schema`, three update idioms (`.set` / `.merge` / `.save`), `.unset`, `.destroy`, `static toJsonColumns` / `resource` for output shaping. Triggers: `Model`, `RegisterModel`, `static schema`, `.set`, `.merge`, `.save`, `.unset`, `.destroy`, `toJsonColumns`, `resource`; "how do I define a model", "shape the JSON output", "remove a field"; typical import `import { Model, RegisterModel } from "@warlock.js/cascade"`. Skip: querying — `@warlock.js/cascade/query-data/SKILL.md`; relations — `@warlock.js/cascade/define-relations/SKILL.md`; competing libs `mongoose`, `prisma`, `typeorm` `@Entity`.
14
14
  - [define-relations](@warlock.js/cascade/define-relations/SKILL.md): Define and query relations — `@BelongsTo` / `@HasMany` / `@BelongsToMany`, `.with("relation")` eager loading, `.whereHas(relation, cb)` filter-by-related, `setRelation` on save, `.joinWith` for SQL joins, `loadRelation`, `lazy(() => Model)`. Triggers: `@BelongsTo`, `@HasMany`, `@BelongsToMany`, `.with`, `.whereHas`, `setRelation`, `.joinWith`, `lazy`; "define a relation", "avoid N+1", "eager load posts", "filter parents by child"; typical import `import { BelongsTo, HasMany, BelongsToMany } from "@warlock.js/cascade"`. Skip: model basics — `@warlock.js/cascade/define-model/SKILL.md`; competing libs `mongoose populate`, `prisma include`, `typeorm relations`.
15
15
  - [manage-data-sources](@warlock.js/cascade/manage-data-sources/SKILL.md): Configure multiple databases — register each via `connectToDatabase({ name, driver, database, isDefault })`, assign a model with `static dataSource = "name"`, route a migration with `dataSource` on the migration class, inspect via `dataSourceRegistry.get(name)` / `getAllDataSources()`. The first (or `isDefault: true`) source is the default. Triggers: `connectToDatabase`, `dataSourceRegistry`, `dataSourceRegistry.get`, `getAllDataSources`, `static dataSource`; "multi-database app", "per-tenant DB", "analytics on separate DB"; typical import `import { connectToDatabase, dataSourceRegistry } from "@warlock.js/cascade"`. Skip: per-source migrations — `@warlock.js/cascade/write-migration/SKILL.md`; transaction scope — `@warlock.js/cascade/manage-transactions/SKILL.md`; competing patterns `mongoose.createConnection`, `typeorm` `DataSource`, `prisma` multi-schema.
16
- - [manage-transactions](@warlock.js/cascade/manage-transactions/SKILL.md): Wrap multi-statement work in `transaction(async () => {...})` — rollback on throw, commit on resolve, optional `isolation` level (Postgres), per-`dataSource` scope. Postgres native; MongoDB requires replica set. Triggers: `transaction`, `isolation`, `SERIALIZABLE`, `READ COMMITTED`, nested savepoints; "wrap two writes atomically", "transfer balance between accounts", "rollback on error", "MongoDB replica set transactions"; typical import `import { transaction } from "@warlock.js/cascade"`. Skip: single-row atomic ops without a transaction — `@warlock.js/cascade/perform-atomic-ops/SKILL.md`; per-source scope — `@warlock.js/cascade/manage-data-sources/SKILL.md`; competing patterns `mongoose.startSession`, `pg` `BEGIN` manually, `prisma.$transaction`, `typeorm` `QueryRunner`.
16
+ - [manage-transactions](@warlock.js/cascade/manage-transactions/SKILL.md): Wrap multi-statement work in `transaction(async () => {...})` — rollback on throw, commit on resolve, optional `isolation` level (Postgres), per-`dataSource` scope. Also the home for transaction-aware raw SQL (`Model.raw` / `DataSource.raw` → `RawQueryResult`) and the Postgres `nativeArrayColumns` connection option. Postgres native; MongoDB requires replica set. Triggers: `transaction`, `isolation`, `SERIALIZABLE`, `READ COMMITTED`, nested savepoints, `Model.raw`, `DataSource.raw`, raw SQL, `RawQueryResult`, `nativeArrayColumns`, `JSONB[]`, `TEXT[]`; "wrap two writes atomically", "transfer balance between accounts", "rollback on error", "MongoDB replica set transactions", "run raw SQL", "native array column"; typical import `import { transaction } from "@warlock.js/cascade"`. Skip: single-row atomic ops without a transaction — `@warlock.js/cascade/perform-atomic-ops/SKILL.md`; per-source scope — `@warlock.js/cascade/manage-data-sources/SKILL.md`; competing patterns `mongoose.startSession`, `pg` `BEGIN` manually, `prisma.$transaction`, `typeorm` `QueryRunner`.
17
17
  - [paginate-results](@warlock.js/cascade/paginate-results/SKILL.md): Paginate query results — `.paginate({page, limit, filter?})` for offset (returns `data` + `pagination` total/page/limit/pages), `.cursorPaginate({limit, cursor})` for very large datasets, `.chunk(size, callback)` for streaming. Triggers: `.paginate`, `.cursorPaginate`, `.chunk`, `nextCursor`, `hasMore`, `pagination.total`; "paginate the list", "infinite scroll / load more", "stream a large table", "page 2 of users"; typical import `import { Model } from "@warlock.js/cascade"`. Skip: filter chain — `@warlock.js/cascade/query-data/SKILL.md`; eager loading on pages — `@warlock.js/cascade/define-relations/SKILL.md`; competing libs `mongoose-paginate-v2`, `prisma` cursor, `typeorm-pagination`.
18
- - [perform-atomic-ops](@warlock.js/cascade/perform-atomic-ops/SKILL.md): Avoid races on concurrent writes — `Model.increase(filter, field, n)` / `Model.decrease` for atomic counters, `Model.atomic(filter, ops)` for arbitrary mutations (`$set` / `$inc` / `$push` / `$pull`), `Model.createMany` / `Model.findAndUpdate` / `Model.delete` for bulk. Triggers: `Model.increase`, `Model.decrease`, `Model.atomic`, `Model.createMany`, `Model.findAndUpdate`, `Model.delete`, `$inc`, `$set`; "increment counter under concurrency", "bulk insert without N+1", "atomic update without loading"; typical import `import { Model } from "@warlock.js/cascade"`. Skip: multi-row atomicity — `@warlock.js/cascade/manage-transactions/SKILL.md`; competing patterns `mongoose findOneAndUpdate`, `pg` `UPDATE ... SET x = x + 1`.
18
+ - [perform-atomic-ops](@warlock.js/cascade/perform-atomic-ops/SKILL.md): Avoid races on concurrent writes — `Model.increase(filter, field, n)` / `Model.decrease` for atomic counters, `Model.atomic(filter, ops)` for arbitrary mutations (`$set` / `$inc` / `$push` / `$pull`), `Model.createMany` / `Model.findAndUpdate` / `Model.delete` for bulk. Triggers: `Model.increase`, `Model.decrease`, `Model.atomic`, `Model.createMany`, `createMany bulk`, `batchSize`, `Model.findAndUpdate`, `Model.delete`, `$inc`, `$set`; "increment counter under concurrency", "bulk insert without N+1", "fast bulk insert", "insert thousands of rows", "atomic update without loading"; typical import `import { Model } from "@warlock.js/cascade"`. Skip: multi-row atomicity — `@warlock.js/cascade/manage-transactions/SKILL.md`; competing patterns `mongoose findOneAndUpdate`, `pg` `UPDATE ... SET x = x + 1`.
19
19
  - [query-data](@warlock.js/cascade/query-data/SKILL.md): Query records via the model — `.where(field, value)` / `.where(field, op, value)`, `.find(id)` / `.first` / `.all`, `.orderBy`, `.count` / `.exists`, plus `.whereIn` / `.whereBetween` / `.whereLike` / `.pluck` / `.firstOrFail` / scopes via `addScope`. Triggers: `.where`, `.find`, `.first`, `.firstOrFail`, `.all`, `.get`, `.orderBy`, `.exists`, `.whereIn`, `.whereBetween`, `addScope`; "filter by status", "find by id", "fetch active users", "check existence"; typical import `import { Model } from "@warlock.js/cascade"`. Skip: pagination — `@warlock.js/cascade/paginate-results/SKILL.md`; aggregates — `@warlock.js/cascade/aggregate-data/SKILL.md`.
20
20
  - [run-cascade-cli](@warlock.js/cascade/run-cascade-cli/SKILL.md): Cascade's standalone `cascade` binary + the Operations API it wraps — `cascade migrate` / `migrate:list` / `migrate:rollback` / `migrate:export-sql`, and `runMigrations` / `rollbackMigrations` / `freshMigrate` / `exportMigrationsSQL` / `listExecutedMigrations` / `createDatabase` / `dropAllTables` / `migrationRunner`. Triggers: `cascade migrate`, `migrate:list`, `migrate:rollback`, `migrate:export-sql`, `runMigrations`, `rollbackMigrations`, `freshMigrate`, `exportMigrationsSQL`, `listExecutedMigrations`, `migrationRunner`; "run migrations in deploy/CI", "reset DB for tests", "programmatic migration", "foreign key constraint cannot be implemented", `CASCADE_PRIMARY_KEY`; typical import `import { runMigrations, migrationRunner } from "@warlock.js/cascade"`. Skip: writing migration files — `@warlock.js/cascade/write-migration/SKILL.md`; competing tools `knex migrate:latest`, `prisma migrate deploy`, `typeorm migration:run`.
21
21
  - [search-by-vector](@warlock.js/cascade/search-by-vector/SKILL.md): Vector similarity search via `.similarTo(column, embedding, alias?)` — adds a similarity `score` column and orders by vector distance so the index is used; cap results with `.limit()`. Postgres uses pgvector (IVFFlat index via `this.vectorIndex`); MongoDB needs Atlas. Schema: `this.vector(column, dimensions)` + `this.vectorIndex(column, { dimensions, similarity })`. Triggers: `.similarTo`, `this.vector`, `this.vectorIndex`, `.whereFullText`, pgvector; "semantic search", "RAG retrieval", "find similar articles", "hybrid vector + full-text"; typical import `import { Model } from "@warlock.js/cascade"`. Skip: query basics — `@warlock.js/cascade/query-data/SKILL.md`; semantic cache — `@warlock.js/cache/use-cache-similarity/SKILL.md`; competing libs `pgvector` directly, `chromadb`, `pinecone`, `weaviate`, `qdrant`.
package/package.json CHANGED
@@ -27,9 +27,9 @@
27
27
  "@mongez/events": "^2.2.6",
28
28
  "@mongez/reinforcements": "^3.3.0",
29
29
  "@mongez/supportive-is": "^2.1.3",
30
- "@warlock.js/context": "4.4.0",
31
- "@warlock.js/logger": "4.4.0",
32
- "@warlock.js/seal": "4.4.0",
30
+ "@warlock.js/context": "4.6.0",
31
+ "@warlock.js/logger": "4.6.0",
32
+ "@warlock.js/seal": "4.6.0",
33
33
  "citty": "^0.2.2",
34
34
  "fast-glob": "^3.3.3"
35
35
  },
@@ -40,7 +40,7 @@
40
40
  "bin": {
41
41
  "cascade": "bin/cascade.js"
42
42
  },
43
- "version": "4.4.0",
43
+ "version": "4.6.0",
44
44
  "main": "./cjs/index.cjs",
45
45
  "module": "./esm/index.mjs",
46
46
  "types": "./esm/index.d.mts",
package/skills/README.md CHANGED
@@ -10,7 +10,7 @@ Evolve an existing table with `Migration.alter(Model, schema, options?)` — add
10
10
 
11
11
  ### [`aggregate-data/`](./aggregate-data/SKILL.md)
12
12
 
13
- Compute aggregates over a query — `.count()`, `.sum(field)`, `.avg`, `.min`, `.max`, plus the two-arg `.groupBy(fields, { alias: $agg.* })` + `.having` for group-level rollups. Load when building reports, dashboards, 'X per category' rollups, or any query returning numbers rather than rows.
13
+ Compute aggregates over a query — `.count()`, `.sum(field)`, `.avg`, `.min`, `.max`, the two-arg `.groupBy(fields, { alias: $agg.* })` + `.having`, portable date-bucketing via `.groupByDate(col, unit, aggregates?)`, and expression-aware `$agg.sum(mul("price","quantity"))` / `$agg.sumRaw` for group-level rollups. Load when building reports, dashboards, 'X per category' rollups, time-series ('revenue per month'), or any query returning numbers rather than rows.
14
14
 
15
15
  ### [`cascade-basics/`](./cascade-basics/SKILL.md)
16
16
 
@@ -34,7 +34,7 @@ Configure multiple databases — register each via `connectToDatabase({ name, dr
34
34
 
35
35
  ### [`manage-transactions/`](./manage-transactions/SKILL.md)
36
36
 
37
- Wrap multi-statement work in transaction(async () => {...}) — rollback on throw or `ctx.rollback()`, commit on resolve, `isolationLevel` option (Postgres). Postgres native, MongoDB requires replica set; not nestable. Load when two or more writes must succeed or fail together (creating parent + children, transferring balances, multi-step state machines).
37
+ Wrap multi-statement work in transaction(async () => {...}) — rollback on throw or `ctx.rollback()`, commit on resolve, `isolationLevel` option (Postgres). Postgres native, MongoDB requires replica set; not nestable. Also covers transaction-aware raw SQL (`Model.raw` / `DataSource.raw` → `RawQueryResult`) and the Postgres `nativeArrayColumns` connection option. Load when two or more writes must succeed or fail together (creating parent + children, transferring balances, multi-step state machines), or when running raw SQL.
38
38
 
39
39
  ### [`paginate-results/`](./paginate-results/SKILL.md)
40
40
 
@@ -42,7 +42,7 @@ Paginate query results — `.paginate({ page, limit })` for offset (returns `dat
42
42
 
43
43
  ### [`perform-atomic-ops/`](./perform-atomic-ops/SKILL.md)
44
44
 
45
- Avoid races on concurrent writes — Model.increase(filter, field, n) / Model.decrease for atomic counters, Model.atomic(filter, ops) for arbitrary atomic mutations, Model.createMany / Model.findAndUpdate / Model.delete for bulk. Load when incrementing counters under concurrency, bulk ops without N+1, or single-document atomic mutations without a full transaction.
45
+ Avoid races on concurrent writes — Model.increase(filter, field, n) / Model.decrease for atomic counters, Model.atomic(filter, ops) for arbitrary atomic mutations, Model.createMany(rows, { bulk, batchSize }) / Model.findAndUpdate / Model.delete for bulk. Load when incrementing counters under concurrency, fast bulk inserts (native multi-row insertMany), bulk ops without N+1, or single-document atomic mutations without a full transaction.
46
46
 
47
47
  ### [`query-data/`](./query-data/SKILL.md)
48
48
 
@@ -1,6 +1,6 @@
1
1
  ---
2
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.* })` with the `$agg` helpers and `.having(alias, op, value)` on computed aggregates. Triggers: `.count`, `.sum`, `.avg`, `.min`, `.max`, `.groupBy`, `.having`, `$agg`, `$agg.sum`, `$agg.count`; "monthly revenue report", "X per category", "group by status", "dashboard rollup"; typical import `import { Model, $agg } 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`.'
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. Triggers: `.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
4
  ---
5
5
 
6
6
  # Use aggregates and groupBy
@@ -43,11 +43,31 @@ const stats = await Order.query()
43
43
 
44
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
45
 
46
- ### `$agg` helpers — five cross-driver, four MongoDB-only
46
+ ### `$agg` helpers — six cross-driver, four MongoDB-only
47
47
 
48
48
  Cross-driver (identical call on MongoDB **and** Postgres):
49
49
 
50
- - `$agg.count()` · `$agg.sum(field)` · `$agg.avg(field)` · `$agg.min(field)` · `$agg.max(field)`
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.
51
71
 
52
72
  MongoDB-only — on Postgres these **throw at the `.groupBy()` call** with an actionable message (there is no honest single-scalar `GROUP BY` equivalent):
53
73
 
@@ -66,6 +86,26 @@ When `$agg.*` can't express it, pass a raw expression in the same slot:
66
86
 
67
87
  Raw strings pass through verbatim. A MongoDB operator object passed on Postgres throws ("not portable to SQL") — keep raw expressions driver-correct.
68
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
+
69
109
  ## `.having(...)` — filter groups by a computed aggregate
70
110
 
71
111
  `.where()` filters rows *before* grouping (cheap, uses indexes). `.having()` filters *after* aggregation, by the alias you defined:
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: manage-transactions
3
- description: 'Wrap multi-statement work in `transaction(async () => {...})` — rollback on throw, commit on resolve, optional `isolation` level (Postgres), per-`dataSource` scope. Postgres native; MongoDB requires replica set. Triggers: `transaction`, `isolation`, `SERIALIZABLE`, `READ COMMITTED`, nested savepoints; "wrap two writes atomically", "transfer balance between accounts", "rollback on error", "MongoDB replica set transactions"; typical import `import { transaction } from "@warlock.js/cascade"`. Skip: single-row atomic ops without a transaction — `@warlock.js/cascade/perform-atomic-ops/SKILL.md`; per-source scope — `@warlock.js/cascade/manage-data-sources/SKILL.md`; competing patterns `mongoose.startSession`, `pg` `BEGIN` manually, `prisma.$transaction`, `typeorm` `QueryRunner`.'
3
+ description: 'Wrap multi-statement work in `transaction(async () => {...})` — rollback on throw, commit on resolve, optional `isolation` level (Postgres), per-`dataSource` scope. Also the home for transaction-aware raw SQL (`Model.raw` / `DataSource.raw` → `RawQueryResult`) and the Postgres `nativeArrayColumns` connection option. Postgres native; MongoDB requires replica set. Triggers: `transaction`, `isolation`, `SERIALIZABLE`, `READ COMMITTED`, nested savepoints, `Model.raw`, `DataSource.raw`, raw SQL, `RawQueryResult`, `nativeArrayColumns`, `JSONB[]`, `TEXT[]`; "wrap two writes atomically", "transfer balance between accounts", "rollback on error", "MongoDB replica set transactions", "run raw SQL", "native array column"; typical import `import { transaction } from "@warlock.js/cascade"`. Skip: single-row atomic ops without a transaction — `@warlock.js/cascade/perform-atomic-ops/SKILL.md`; per-source scope — `@warlock.js/cascade/manage-data-sources/SKILL.md`; competing patterns `mongoose.startSession`, `pg` `BEGIN` manually, `prisma.$transaction`, `typeorm` `QueryRunner`.'
4
4
  ---
5
5
 
6
6
  # Use transactions
@@ -101,6 +101,45 @@ On `SERIALIZABLE`, Postgres may abort with a serialization failure when concurre
101
101
 
102
102
  Once the callback returns, the transaction is committed. Subsequent calls — including reads — see the committed state. Don't try to "share" a model instance between inside-transaction and outside contexts; reload outside if you need fresh state.
103
103
 
104
+ ## Raw SQL — `Model.raw` / `DataSource.raw` (transaction-aware)
105
+
106
+ When the query builder can't express something, drop to raw SQL. Both helpers are **transaction-aware**: called inside an active `transaction()` scope they auto-join that transaction's client/session, otherwise they run on the pool.
107
+
108
+ ```ts
109
+ // Off a model — uses the model's driver
110
+ const { rows, rowCount } = await User.raw<{ id: number; total: number }>(
111
+ "SELECT id, COUNT(*) AS total FROM orders WHERE user_id = $1 GROUP BY id",
112
+ [userId],
113
+ );
114
+
115
+ // Inside a transaction — auto-joins the active scope
116
+ await transaction(async () => {
117
+ await User.raw("UPDATE users SET active = true WHERE id = $1", [id]);
118
+ });
119
+
120
+ // Off a data source directly
121
+ const { rows: counts } = await dataSource.raw<{ count: number }>(
122
+ "SELECT COUNT(*)::int AS count FROM users",
123
+ );
124
+ ```
125
+
126
+ `Model.raw<T>(sql, params?)` and `DataSource.raw<T>(sql, params?)` both return `Promise<RawQueryResult<T>>` — `{ rows: T[]; rowCount: number }`, **not** hydrated model instances. MongoDB drivers **throw**: there is no raw SQL on Mongo.
127
+
128
+ ### `nativeArrayColumns` (Postgres connection option)
129
+
130
+ The Postgres serializer can't see the table schema, so by default it `JSON.stringify`s every non-vector array (and plain object) to bind it as JSON text — the correct form for a `json` / `jsonb` column. For a genuine **native-array** column (`JSONB[]` / `TEXT[]` / `INTEGER[]`, e.g. from `arrayJson()` / `arrayText()`) that's wrong: node-pg must receive the raw JS array so it emits a `{...}` array literal. List those columns in the connection's `nativeArrayColumns` to opt them out of JSON-text encoding:
131
+
132
+ ```ts
133
+ connectToDatabase({
134
+ name: "default",
135
+ driver: "postgres",
136
+ // ...
137
+ nativeArrayColumns: ["tags", "category_ids"], // genuine TEXT[] / INTEGER[] columns
138
+ });
139
+ ```
140
+
141
+ The pgvector all-number array form is preserved automatically — you only need this for non-vector native arrays.
142
+
104
143
  ## Side effects after commit — the outbox pattern
105
144
 
106
145
  For "side effects must only happen if the transaction succeeded" (publish to a queue, send an email, write to a search index), don't run them inside the transaction. Use the outbox pattern: write a row to an outbox table inside the transaction, dispatch from the outbox in a separate worker after commit.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: perform-atomic-ops
3
- description: 'Avoid races on concurrent writes — `Model.increase(filter, field, n)` / `Model.decrease` for atomic counters, `Model.atomic(filter, ops)` for arbitrary mutations (`$set` / `$inc` / `$push` / `$pull`), `Model.createMany` / `Model.findAndUpdate` / `Model.delete` for bulk. Triggers: `Model.increase`, `Model.decrease`, `Model.atomic`, `Model.createMany`, `Model.findAndUpdate`, `Model.delete`, `$inc`, `$set`; "increment counter under concurrency", "bulk insert without N+1", "atomic update without loading"; typical import `import { Model } from "@warlock.js/cascade"`. Skip: multi-row atomicity — `@warlock.js/cascade/manage-transactions/SKILL.md`; competing patterns `mongoose findOneAndUpdate`, `pg` `UPDATE ... SET x = x + 1`.'
3
+ description: 'Avoid races on concurrent writes — `Model.increase(filter, field, n)` / `Model.decrease` for atomic counters, `Model.atomic(filter, ops)` for arbitrary mutations (`$set` / `$inc` / `$push` / `$pull`), `Model.createMany` / `Model.findAndUpdate` / `Model.delete` for bulk. Triggers: `Model.increase`, `Model.decrease`, `Model.atomic`, `Model.createMany`, `createMany bulk`, `batchSize`, `Model.findAndUpdate`, `Model.delete`, `$inc`, `$set`; "increment counter under concurrency", "bulk insert without N+1", "fast bulk insert", "insert thousands of rows", "atomic update without loading"; typical import `import { Model } from "@warlock.js/cascade"`. Skip: multi-row atomicity — `@warlock.js/cascade/manage-transactions/SKILL.md`; competing patterns `mongoose findOneAndUpdate`, `pg` `UPDATE ... SET x = x + 1`.'
4
4
  ---
5
5
 
6
6
  # Use atomic operations
@@ -38,7 +38,25 @@ const created = await OrderItem.createMany([
38
38
  // created: OrderItem[]
39
39
  ```
40
40
 
41
- `Model.createMany(rows)` → `Promise<TModel[]>`. Validation runs per row; wrap in a transaction if you need strict all-or-nothing semantics.
41
+ `Model.createMany(rows, options?)` → `Promise<TModel[]>`. `options` is `{ batchSize?: number; bulk?: boolean }`. Validation runs per row; wrap in a transaction if you need strict all-or-nothing semantics.
42
+
43
+ ### `batchSize` — chunking (both paths)
44
+
45
+ A huge array is processed in sequential chunks so it can't flood the driver. `batchSize` sets the chunk size; the default is **500** (`DEFAULT_CREATE_MANY_BATCH_SIZE`). Each chunk completes before the next starts:
46
+
47
+ ```ts
48
+ await OrderItem.createMany(millionRows, { batchSize: 1000 });
49
+ ```
50
+
51
+ ### `bulk: true` — native multi-row insert (10–100× faster)
52
+
53
+ The default path persists each row through `save()`, so model hooks, lifecycle events (`saving` / `creating` / `created` / `saved`), casts, and generated ids are all preserved. Pass `bulk: true` to instead route each chunk to the driver's native multi-row `insertMany` for 10–100× throughput on large arrays:
54
+
55
+ ```ts
56
+ const rows = await OrderItem.createMany(millionRows, { bulk: true, batchSize: 1000 });
57
+ ```
58
+
59
+ **Tradeoff.** The bulk path SKIPS the per-row lifecycle — no `saving` / `creating` / `created` / `saved` events, no instance hooks, no sync. What it KEEPS: rows are still prepped through the writer pipeline, so validation, casts, timestamps, defaults, and id-generation still run and the persisted columns match the default path; driver-returned values (generated `_id`, timestamps, SQL `RETURNING *`) are merged back onto the returned models. Reach for `bulk: true` when inserting large batches where per-row events don't matter (seeders, imports, denormalization); stay on the default path when you need the hooks/events.
42
60
 
43
61
  ## Bulk update — `Model.findAndUpdate(filter, operations)`
44
62
 
@@ -79,6 +97,7 @@ for (const user of targets) {
79
97
  | Increment a counter | `Model.increase(filter, field, n)` |
80
98
  | Atomically change multiple fields on one record | `Model.atomic(filter, ops)` |
81
99
  | Insert N records | `Model.createMany(rows)` |
100
+ | Insert a large batch fast (no per-row events) | `Model.createMany(rows, { bulk: true, batchSize })` |
82
101
  | Update many rows with operators | `Model.findAndUpdate(filter, { $set: {...} })` |
83
102
  | Update one record by id | `Model.update(id, data)` |
84
103
  | Delete many rows (raw) | `Model.delete(filter)` |
@@ -90,7 +109,8 @@ for (const user of targets) {
90
109
  - Don't `const post = await Post.find(id); post.set("views", post.get<number>("views") + 1); await post.save();` for a counter. That's a lost-update race under concurrency. Use `Post.increase(filter, "views", 1)`.
91
110
  - Don't reach for `insertMany` / `updateMany` / `deleteMany` — those names don't exist on the model. Use `createMany` / `findAndUpdate` / `delete`.
92
111
  - Don't expect `findAndUpdate` / `delete` to fire per-row `saved` / `deleted` events or honor the delete strategy. They don't. Iterate if you need that.
93
- - Don't bulk-insert a million rows in one `createMany` call chunk it. Most drivers cap effectively at a few thousand per round-trip.
112
+ - Don't hand-roll chunking around `createMany` — it already chunks by `batchSize` (default 500). Tune `batchSize` instead of slicing the array yourself.
113
+ - Don't assume `bulk: true` fires per-row `saving` / `created` / `saved` events or runs instance hooks — it doesn't. Casts/timestamps/defaults/ids still apply, but if you need the lifecycle, use the default path (or iterate with `.save()`).
94
114
 
95
115
  ## See also
96
116