@warlock.js/cascade 4.5.0 → 4.6.1

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 (78) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/cjs/index.cjs +1012 -104
  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 +5 -4
  18. package/esm/drivers/mongodb/mongodb-driver.mjs.map +1 -1
  19. package/esm/drivers/mongodb/mongodb-id-generator.d.mts +98 -48
  20. package/esm/drivers/mongodb/mongodb-id-generator.d.mts.map +1 -1
  21. package/esm/drivers/mongodb/mongodb-id-generator.mjs +153 -59
  22. package/esm/drivers/mongodb/mongodb-id-generator.mjs.map +1 -1
  23. package/esm/drivers/mongodb/mongodb-query-builder.d.mts +25 -0
  24. package/esm/drivers/mongodb/mongodb-query-builder.d.mts.map +1 -1
  25. package/esm/drivers/mongodb/mongodb-query-builder.mjs +32 -0
  26. package/esm/drivers/mongodb/mongodb-query-builder.mjs.map +1 -1
  27. package/esm/drivers/mongodb/mongodb-query-parser.d.mts +36 -0
  28. package/esm/drivers/mongodb/mongodb-query-parser.d.mts.map +1 -1
  29. package/esm/drivers/mongodb/mongodb-query-parser.mjs +80 -1
  30. package/esm/drivers/mongodb/mongodb-query-parser.mjs.map +1 -1
  31. package/esm/drivers/postgres/postgres-dialect.d.mts +32 -4
  32. package/esm/drivers/postgres/postgres-dialect.d.mts.map +1 -1
  33. package/esm/drivers/postgres/postgres-dialect.mjs +57 -4
  34. package/esm/drivers/postgres/postgres-dialect.mjs.map +1 -1
  35. package/esm/drivers/postgres/postgres-driver.d.mts +83 -1
  36. package/esm/drivers/postgres/postgres-driver.d.mts.map +1 -1
  37. package/esm/drivers/postgres/postgres-driver.mjs +129 -16
  38. package/esm/drivers/postgres/postgres-driver.mjs.map +1 -1
  39. package/esm/drivers/postgres/postgres-query-builder.d.mts +32 -0
  40. package/esm/drivers/postgres/postgres-query-builder.d.mts.map +1 -1
  41. package/esm/drivers/postgres/postgres-query-builder.mjs +47 -1
  42. package/esm/drivers/postgres/postgres-query-builder.mjs.map +1 -1
  43. package/esm/drivers/postgres/postgres-query-parser.d.mts +13 -2
  44. package/esm/drivers/postgres/postgres-query-parser.d.mts.map +1 -1
  45. package/esm/drivers/postgres/postgres-query-parser.mjs +21 -4
  46. package/esm/drivers/postgres/postgres-query-parser.mjs.map +1 -1
  47. package/esm/drivers/postgres/types.d.mts +15 -0
  48. package/esm/drivers/postgres/types.d.mts.map +1 -1
  49. package/esm/drivers/sql/sql-dialect.contract.d.mts +20 -0
  50. package/esm/drivers/sql/sql-dialect.contract.d.mts.map +1 -1
  51. package/esm/expressions/aggregate-expressions.d.mts +71 -34
  52. package/esm/expressions/aggregate-expressions.d.mts.map +1 -1
  53. package/esm/expressions/aggregate-expressions.mjs +80 -7
  54. package/esm/expressions/aggregate-expressions.mjs.map +1 -1
  55. package/esm/expressions/column-expressions.d.mts +193 -0
  56. package/esm/expressions/column-expressions.d.mts.map +1 -0
  57. package/esm/expressions/column-expressions.mjs +152 -0
  58. package/esm/expressions/column-expressions.mjs.map +1 -0
  59. package/esm/index.d.mts +3 -2
  60. package/esm/index.mjs +2 -1
  61. package/esm/model/methods/write-methods.d.mts +29 -0
  62. package/esm/model/methods/write-methods.d.mts.map +1 -0
  63. package/esm/model/methods/write-methods.mjs +164 -2
  64. package/esm/model/methods/write-methods.mjs.map +1 -1
  65. package/esm/model/model.d.mts +64 -3
  66. package/esm/model/model.d.mts.map +1 -1
  67. package/esm/model/model.mjs +65 -3
  68. package/esm/model/model.mjs.map +1 -1
  69. package/esm/writer/database-writer.d.mts.map +1 -1
  70. package/esm/writer/database-writer.mjs +4 -3
  71. package/esm/writer/database-writer.mjs.map +1 -1
  72. package/llms-full.txt +111 -8
  73. package/llms.txt +3 -3
  74. package/package.json +4 -4
  75. package/skills/README.md +3 -3
  76. package/skills/aggregate-data/SKILL.md +43 -3
  77. package/skills/manage-transactions/SKILL.md +45 -2
  78. 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 Postgres native-array column handling (`JSONB[]` / `TEXT[]` / `INTEGER[]` auto-detected via schema introspection on connect; `nativeArrayColumns` is an optional override). Postgres native; MongoDB requires replica set. Triggers: `transaction`, `isolation`, `SERIALIZABLE`, `READ COMMITTED`, nested transaction, flat nesting, 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", "malformed array literal", "array column not saving", "nested transaction not visible", "foreign key violation on insert inside transaction", "service transaction inside seeder"; 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
@@ -1460,7 +1500,9 @@ Postgres has no such requirement — transactions work out of the box.
1460
1500
 
1461
1501
  ## Nesting
1462
1502
 
1463
- The function-shaped `transaction(fn)` is **not** nestable — calling it inside an already-open transaction is not supported. For nested scope on Postgres, drop to the manual API (`driver.beginTransaction()`) and use savepoints explicitly. For most app code, keep a single top-level `transaction(fn)` and let any inner failure abort the whole flow.
1503
+ `transaction(fn)` **flat-nests**: calling it inside an already-open transaction JOINS the outer one instead of opening a second, independent transaction. The inner block runs on the **same** session — so it sees the outer's uncommitted writes — and the **outermost** `transaction()` owns commit/rollback. A service that opens its own `transaction()` for standalone atomicity therefore also works unchanged when called inside a larger transaction — e.g. a seeder that creates a row, then calls a service that opens a transaction to insert a child referencing it (an independent inner transaction couldn't see the parent's uncommitted row, and the child insert would fail its foreign key).
1504
+
1505
+ A throw anywhere inside unwinds the **whole** outer transaction — Postgres aborts a transaction on the first error, so there is no automatic per-block savepoint. For independent partial rollback of an inner block, drop to the manual API (`driver.beginTransaction()`) and use savepoints explicitly.
1464
1506
 
1465
1507
  ## Explicit rollback
1466
1508
 
@@ -1493,6 +1535,47 @@ On `SERIALIZABLE`, Postgres may abort with a serialization failure when concurre
1493
1535
 
1494
1536
  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
1537
 
1538
+ ## Raw SQL — `Model.raw` / `DataSource.raw` (transaction-aware)
1539
+
1540
+ 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.
1541
+
1542
+ ```ts
1543
+ // Off a model — uses the model's driver
1544
+ const { rows, rowCount } = await User.raw<{ id: number; total: number }>(
1545
+ "SELECT id, COUNT(*) AS total FROM orders WHERE user_id = $1 GROUP BY id",
1546
+ [userId],
1547
+ );
1548
+
1549
+ // Inside a transaction — auto-joins the active scope
1550
+ await transaction(async () => {
1551
+ await User.raw("UPDATE users SET active = true WHERE id = $1", [id]);
1552
+ });
1553
+
1554
+ // Off a data source directly
1555
+ const { rows: counts } = await dataSource.raw<{ count: number }>(
1556
+ "SELECT COUNT(*)::int AS count FROM users",
1557
+ );
1558
+ ```
1559
+
1560
+ `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.
1561
+
1562
+ ### Native-array columns (`JSONB[]` / `TEXT[]` / `INTEGER[]`)
1563
+
1564
+ From a value alone the serializer can't tell a `json` / `jsonb` column (which needs JSON text) from a genuine **native-array** column (`arrayJson()` → `JSONB[]`, `arrayText()` → `TEXT[]`, …, which needs the raw JS array so node-pg emits a `{...}` literal). Guess wrong and Postgres rejects the insert with *"malformed array literal"*.
1565
+
1566
+ **This is handled automatically.** On connect the driver introspects `information_schema` once and remembers which columns are native arrays **per table**, so `arrayText()` / `arrayJson()` columns just work with **no configuration**. Because it's per-table, the same column name being `TEXT[]` in one table and `jsonb` in another is encoded correctly for each; the pgvector all-number array form is still preserved.
1567
+
1568
+ `nativeArrayColumns` remains as an optional, table-agnostic **override** — for a column created outside a migration, or an environment where `information_schema` can't be read:
1569
+
1570
+ ```ts
1571
+ connectToDatabase({
1572
+ name: "default",
1573
+ driver: "postgres",
1574
+ // ...
1575
+ nativeArrayColumns: ["tags", "category_ids"], // optional manual override
1576
+ });
1577
+ ```
1578
+
1496
1579
  ## Side effects after commit — the outbox pattern
1497
1580
 
1498
1581
  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 +1723,7 @@ The total count requires an extra query. On very large filtered tables, this can
1640
1723
 
1641
1724
  ---
1642
1725
  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`.'
1726
+ 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
1727
  ---
1645
1728
 
1646
1729
  # Use atomic operations
@@ -1678,7 +1761,25 @@ const created = await OrderItem.createMany([
1678
1761
  // created: OrderItem[]
1679
1762
  ```
1680
1763
 
1681
- `Model.createMany(rows)` → `Promise<TModel[]>`. Validation runs per row; wrap in a transaction if you need strict all-or-nothing semantics.
1764
+ `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.
1765
+
1766
+ ### `batchSize` — chunking (both paths)
1767
+
1768
+ 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:
1769
+
1770
+ ```ts
1771
+ await OrderItem.createMany(millionRows, { batchSize: 1000 });
1772
+ ```
1773
+
1774
+ ### `bulk: true` — native multi-row insert (10–100× faster)
1775
+
1776
+ 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:
1777
+
1778
+ ```ts
1779
+ const rows = await OrderItem.createMany(millionRows, { bulk: true, batchSize: 1000 });
1780
+ ```
1781
+
1782
+ **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
1783
 
1683
1784
  ## Bulk update — `Model.findAndUpdate(filter, operations)`
1684
1785
 
@@ -1719,6 +1820,7 @@ for (const user of targets) {
1719
1820
  | Increment a counter | `Model.increase(filter, field, n)` |
1720
1821
  | Atomically change multiple fields on one record | `Model.atomic(filter, ops)` |
1721
1822
  | Insert N records | `Model.createMany(rows)` |
1823
+ | Insert a large batch fast (no per-row events) | `Model.createMany(rows, { bulk: true, batchSize })` |
1722
1824
  | Update many rows with operators | `Model.findAndUpdate(filter, { $set: {...} })` |
1723
1825
  | Update one record by id | `Model.update(id, data)` |
1724
1826
  | Delete many rows (raw) | `Model.delete(filter)` |
@@ -1730,7 +1832,8 @@ for (const user of targets) {
1730
1832
  - 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
1833
  - Don't reach for `insertMany` / `updateMany` / `deleteMany` — those names don't exist on the model. Use `createMany` / `findAndUpdate` / `delete`.
1732
1834
  - 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.
1835
+ - Don't hand-roll chunking around `createMany` — it already chunks by `batchSize` (default 500). Tune `batchSize` instead of slicing the array yourself.
1836
+ - 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
1837
 
1735
1838
  ## See also
1736
1839
 
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 Postgres native-array column handling (`JSONB[]` / `TEXT[]` / `INTEGER[]` auto-detected via schema introspection on connect; `nativeArrayColumns` is an optional override). Postgres native; MongoDB requires replica set. Triggers: `transaction`, `isolation`, `SERIALIZABLE`, `READ COMMITTED`, nested transaction, flat nesting, 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", "malformed array literal", "array column not saving", "nested transaction not visible", "foreign key violation on insert inside transaction", "service transaction inside seeder"; 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.5.0",
31
- "@warlock.js/logger": "4.5.0",
32
- "@warlock.js/seal": "4.5.0",
30
+ "@warlock.js/context": "4.6.1",
31
+ "@warlock.js/logger": "4.6.1",
32
+ "@warlock.js/seal": "4.6.1",
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.5.0",
43
+ "version": "4.6.1",
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 Postgres native-array column handling (`JSONB[]` / `TEXT[]` / `INTEGER[]` auto-detected via schema introspection on connect; `nativeArrayColumns` is an optional override). Postgres native; MongoDB requires replica set. Triggers: `transaction`, `isolation`, `SERIALIZABLE`, `READ COMMITTED`, nested transaction, flat nesting, 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", "malformed array literal", "array column not saving", "nested transaction not visible", "foreign key violation on insert inside transaction", "service transaction inside seeder"; 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
@@ -68,7 +68,9 @@ Postgres has no such requirement — transactions work out of the box.
68
68
 
69
69
  ## Nesting
70
70
 
71
- The function-shaped `transaction(fn)` is **not** nestable — calling it inside an already-open transaction is not supported. For nested scope on Postgres, drop to the manual API (`driver.beginTransaction()`) and use savepoints explicitly. For most app code, keep a single top-level `transaction(fn)` and let any inner failure abort the whole flow.
71
+ `transaction(fn)` **flat-nests**: calling it inside an already-open transaction JOINS the outer one instead of opening a second, independent transaction. The inner block runs on the **same** session — so it sees the outer's uncommitted writes — and the **outermost** `transaction()` owns commit/rollback. A service that opens its own `transaction()` for standalone atomicity therefore also works unchanged when called inside a larger transaction — e.g. a seeder that creates a row, then calls a service that opens a transaction to insert a child referencing it (an independent inner transaction couldn't see the parent's uncommitted row, and the child insert would fail its foreign key).
72
+
73
+ A throw anywhere inside unwinds the **whole** outer transaction — Postgres aborts a transaction on the first error, so there is no automatic per-block savepoint. For independent partial rollback of an inner block, drop to the manual API (`driver.beginTransaction()`) and use savepoints explicitly.
72
74
 
73
75
  ## Explicit rollback
74
76
 
@@ -101,6 +103,47 @@ On `SERIALIZABLE`, Postgres may abort with a serialization failure when concurre
101
103
 
102
104
  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
105
 
106
+ ## Raw SQL — `Model.raw` / `DataSource.raw` (transaction-aware)
107
+
108
+ 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.
109
+
110
+ ```ts
111
+ // Off a model — uses the model's driver
112
+ const { rows, rowCount } = await User.raw<{ id: number; total: number }>(
113
+ "SELECT id, COUNT(*) AS total FROM orders WHERE user_id = $1 GROUP BY id",
114
+ [userId],
115
+ );
116
+
117
+ // Inside a transaction — auto-joins the active scope
118
+ await transaction(async () => {
119
+ await User.raw("UPDATE users SET active = true WHERE id = $1", [id]);
120
+ });
121
+
122
+ // Off a data source directly
123
+ const { rows: counts } = await dataSource.raw<{ count: number }>(
124
+ "SELECT COUNT(*)::int AS count FROM users",
125
+ );
126
+ ```
127
+
128
+ `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.
129
+
130
+ ### Native-array columns (`JSONB[]` / `TEXT[]` / `INTEGER[]`)
131
+
132
+ From a value alone the serializer can't tell a `json` / `jsonb` column (which needs JSON text) from a genuine **native-array** column (`arrayJson()` → `JSONB[]`, `arrayText()` → `TEXT[]`, …, which needs the raw JS array so node-pg emits a `{...}` literal). Guess wrong and Postgres rejects the insert with *"malformed array literal"*.
133
+
134
+ **This is handled automatically.** On connect the driver introspects `information_schema` once and remembers which columns are native arrays **per table**, so `arrayText()` / `arrayJson()` columns just work with **no configuration**. Because it's per-table, the same column name being `TEXT[]` in one table and `jsonb` in another is encoded correctly for each; the pgvector all-number array form is still preserved.
135
+
136
+ `nativeArrayColumns` remains as an optional, table-agnostic **override** — for a column created outside a migration, or an environment where `information_schema` can't be read:
137
+
138
+ ```ts
139
+ connectToDatabase({
140
+ name: "default",
141
+ driver: "postgres",
142
+ // ...
143
+ nativeArrayColumns: ["tags", "category_ids"], // optional manual override
144
+ });
145
+ ```
146
+
104
147
  ## Side effects after commit — the outbox pattern
105
148
 
106
149
  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