@warlock.js/cascade 5.12.0 → 5.13.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 (68) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/cjs/index.cjs +685 -128
  3. package/cjs/index.cjs.map +1 -1
  4. package/esm/contracts/database-driver.contract.d.mts +44 -7
  5. package/esm/contracts/database-driver.contract.d.mts.map +1 -1
  6. package/esm/contracts/index.d.mts +2 -2
  7. package/esm/contracts/query-builder.contract.d.mts +61 -1
  8. package/esm/contracts/query-builder.contract.d.mts.map +1 -1
  9. package/esm/drivers/mongodb/mongodb-driver.d.mts +8 -4
  10. package/esm/drivers/mongodb/mongodb-driver.d.mts.map +1 -1
  11. package/esm/drivers/mongodb/mongodb-driver.mjs +16 -6
  12. package/esm/drivers/mongodb/mongodb-driver.mjs.map +1 -1
  13. package/esm/drivers/mongodb/mongodb-query-builder.d.mts +32 -7
  14. package/esm/drivers/mongodb/mongodb-query-builder.d.mts.map +1 -1
  15. package/esm/drivers/mongodb/mongodb-query-builder.mjs +56 -8
  16. package/esm/drivers/mongodb/mongodb-query-builder.mjs.map +1 -1
  17. package/esm/drivers/mongodb/mongodb-query-parser.d.mts +39 -12
  18. package/esm/drivers/mongodb/mongodb-query-parser.d.mts.map +1 -1
  19. package/esm/drivers/mongodb/mongodb-query-parser.mjs +143 -55
  20. package/esm/drivers/mongodb/mongodb-query-parser.mjs.map +1 -1
  21. package/esm/drivers/mongodb/mongodb-update-translator.mjs +25 -0
  22. package/esm/drivers/mongodb/mongodb-update-translator.mjs.map +1 -0
  23. package/esm/drivers/mongodb/pipeline-stage-object.mjs +24 -0
  24. package/esm/drivers/mongodb/pipeline-stage-object.mjs.map +1 -0
  25. package/esm/drivers/mongodb/types.d.mts +7 -1
  26. package/esm/drivers/mongodb/types.d.mts.map +1 -1
  27. package/esm/drivers/postgres/postgres-driver.d.mts +52 -9
  28. package/esm/drivers/postgres/postgres-driver.d.mts.map +1 -1
  29. package/esm/drivers/postgres/postgres-driver.mjs +170 -38
  30. package/esm/drivers/postgres/postgres-driver.mjs.map +1 -1
  31. package/esm/drivers/postgres/postgres-query-builder.d.mts +17 -1
  32. package/esm/drivers/postgres/postgres-query-builder.d.mts.map +1 -1
  33. package/esm/drivers/postgres/postgres-query-builder.mjs +31 -0
  34. package/esm/drivers/postgres/postgres-query-builder.mjs.map +1 -1
  35. package/esm/drivers/postgres/postgres-update-validator.mjs +36 -0
  36. package/esm/drivers/postgres/postgres-update-validator.mjs.map +1 -0
  37. package/esm/errors/unsupported-lean-operation.error.d.mts +25 -0
  38. package/esm/errors/unsupported-lean-operation.error.d.mts.map +1 -0
  39. package/esm/errors/unsupported-lean-operation.error.mjs +31 -0
  40. package/esm/errors/unsupported-lean-operation.error.mjs.map +1 -0
  41. package/esm/errors/unsupported-query-operation.error.d.mts +30 -0
  42. package/esm/errors/unsupported-query-operation.error.d.mts.map +1 -0
  43. package/esm/errors/unsupported-query-operation.error.mjs +37 -0
  44. package/esm/errors/unsupported-query-operation.error.mjs.map +1 -0
  45. package/esm/errors/unsupported-update-operation.error.d.mts +30 -0
  46. package/esm/errors/unsupported-update-operation.error.d.mts.map +1 -0
  47. package/esm/errors/unsupported-update-operation.error.mjs +37 -0
  48. package/esm/errors/unsupported-update-operation.error.mjs.map +1 -0
  49. package/esm/index.d.mts +6 -3
  50. package/esm/index.mjs +4 -1
  51. package/esm/model/methods/query-methods.mjs +22 -6
  52. package/esm/model/methods/query-methods.mjs.map +1 -1
  53. package/esm/model/model.d.mts +28 -11
  54. package/esm/model/model.d.mts.map +1 -1
  55. package/esm/model/model.mjs +30 -13
  56. package/esm/model/model.mjs.map +1 -1
  57. package/esm/query-builder/lean-records.mjs +34 -0
  58. package/esm/query-builder/lean-records.mjs.map +1 -0
  59. package/esm/query-builder/query-builder.d.mts +13 -1
  60. package/esm/query-builder/query-builder.d.mts.map +1 -1
  61. package/esm/query-builder/query-builder.mjs +16 -0
  62. package/esm/query-builder/query-builder.mjs.map +1 -1
  63. package/llms-full.txt +72 -5
  64. package/llms.txt +3 -3
  65. package/package.json +4 -4
  66. package/skills/aggregate-data/SKILL.md +24 -1
  67. package/skills/perform-atomic-ops/SKILL.md +38 -3
  68. package/skills/query-data/SKILL.md +10 -1
package/package.json CHANGED
@@ -13,9 +13,9 @@
13
13
  "@mongez/events": "^2.2.7",
14
14
  "@mongez/reinforcements": "^4.0.1",
15
15
  "@mongez/supportive-is": "^2.1.4",
16
- "@warlock.js/context": "5.12.0",
17
- "@warlock.js/logger": "5.12.0",
18
- "@warlock.js/seal": "5.12.0",
16
+ "@warlock.js/context": "5.13.0",
17
+ "@warlock.js/logger": "5.13.0",
18
+ "@warlock.js/seal": "5.13.0",
19
19
  "citty": "^0.2.2",
20
20
  "fast-glob": "^3.3.3"
21
21
  },
@@ -51,7 +51,7 @@
51
51
  ],
52
52
  "author": "hassanzohdy",
53
53
  "license": "MIT",
54
- "version": "5.12.0",
54
+ "version": "5.13.0",
55
55
  "main": "./cjs/index.cjs",
56
56
  "module": "./esm/index.mjs",
57
57
  "types": "./esm/index.d.mts",
@@ -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.* })`, 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`.'
3
+ description: 'Compute aggregates over a query — scalar `.count()` / `.sum(field)` / `.avg` / `.min` / `.max`, plus grouped rollups via the two-arg `.groupBy(fields, { alias: $agg.* })`, portable date-bucketing via `.groupByDate(col, unit, aggregates?)`, the `$agg` helpers (including expression-aware `$agg.sum($expr.mul("price","quantity"))` / `$agg.sumRaw`), and `.having(alias, op, value)` on computed aggregates, plus MongoDB pipeline stages `.unwind(field, options?)` / `.addFields(fields)` / pipeline-form `join()` (`UnsupportedQueryOperationError` on Postgres). Triggers: `.unwind`, `.addFields`, `.joinRaw`, `.raw`, `$unwind`, `$lookup`, `.count`, `.sum`, `.avg`, `.min`, `.max`, `.groupBy`, `.groupByDate`, `.having`, `$agg`, `$agg.sum`, `$agg.sumRaw`, `$agg.count`, `$expr`, `$expr.mul`, `$expr.col`, `$expr.lit`; "monthly revenue report", "revenue per month", "X per category", "group by status", "sum price times quantity", "dashboard rollup"; typical import `import { Model, $agg, $expr } from "@warlock.js/cascade"`. Skip: row queries — `@warlock.js/cascade/query-data/SKILL.md`; cached aggregates — `@warlock.js/cache/use-cached-hof/SKILL.md`; competing tools raw SQL `GROUP BY`, `mongoose aggregate`, `prisma` `groupBy`.'
4
4
  ---
5
5
 
6
6
  # Use aggregates and groupBy
@@ -130,6 +130,29 @@ await Order.query()
130
130
 
131
131
  The `orderBy` reference matches the alias from the aggregates object.
132
132
 
133
+ ## Pipeline stages — `.unwind()` / `.addFields()` / pipeline `join()` (MongoDB)
134
+
135
+ ```ts
136
+ // One row per tag, then filter the tags (stages run in call order)
137
+ const rows = await Post.query().unwind("tags").where("tags", "news").lean().get();
138
+
139
+ // Keep posts with no tags; record each tag's position
140
+ await Post.query().unwind("tags", { preserveNullAndEmptyArrays: true, includeArrayIndex: "position" }).get();
141
+
142
+ // Computed field, existing fields kept
143
+ await Post.query().addFields({ score: { $add: ["$likes", "$shares"] } }).orderBy("score", "desc").get();
144
+
145
+ // Pipeline $lookup
146
+ await Post.query().join({ table: "comments", localField: "id", foreignField: "postId",
147
+ alias: "approved", pipeline: [{ $match: { approved: true } }] }).lean().get();
148
+ ```
149
+
150
+ `unwind()` returns several rows for one stored document. Use `.lean()` when you only read them. `addFields` expressions are raw aggregation expressions: write them in code, never from request data. Computed columns in the output also work with `selectRaw({ alias: expr })` (`$project`). There is no `$facet` method yet.
151
+
152
+ Raw stages: `joinRaw({ $lookup: { from, let, pipeline, as } })` (or an array of stages) is emitted verbatim in call order. `raw((pipeline) => [...pipeline, { $sample: { size: 5 } }])` gets the pipeline built so far; return a new array or mutate it and return nothing. Anything else (a SQL string, a non-stage object) throws `UnsupportedQueryOperationError`. Build these in code, never from request data.
153
+
154
+ **Postgres:** `unwind()` and `addFields()` throw `UnsupportedQueryOperationError` (`operation`, `driver`) when you call them. They are never dropped without an error. Use `selectRaw` with `jsonb_array_elements()` / `unnest()`, or a related table, instead.
155
+
133
156
  ## Gotchas
134
157
 
135
158
  - **`where` vs `having`.** Row filters go in `.where()` (before grouping, index-friendly); aggregate filters go in `.having()`.
@@ -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. `atomic()` / `findAndUpdate()` / `findOneAndUpdate()` / `findAndReplace()` / `findOneAndDelete()` sanitize their `filter` argument — `$`-prefixed keys throw `UnsafeFilterError`, same check as `where()`. Triggers: `Model.increase`, `Model.decrease`, `Model.atomic`, `Model.createMany`, `createMany bulk`, `batchSize`, `Model.findAndUpdate`, `Model.findOneAndUpdate`, `Model.findAndReplace`, `Model.findOneAndDelete`, `Model.delete`, `$inc`, `$set`, `UnsafeFilterError`; "increment counter under concurrency", "bulk insert without N+1", "fast bulk insert", "insert thousands of rows", "atomic update without loading", "is atomic() safe with a request body filter"; 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, options?)` for arbitrary mutations (`$set` / `$inc` / `$push` / `$pull` / `$addToSet` / `$setOnInsert`, pipeline updates, `upsert`, `returnDocument`, `arrayFilters`, `trustedFilter`), `Model.createMany` / `Model.findAndUpdate` / `Model.delete` for bulk. `atomic()` / `findAndUpdate()` / `findOneAndUpdate()` / `findAndReplace()` / `findOneAndDelete()` sanitize their `filter` argument — `$`-prefixed keys throw `UnsafeFilterError`, same check as `where()`. Triggers: `Model.increase`, `Model.decrease`, `Model.atomic`, `Model.createMany`, `createMany bulk`, `batchSize`, `Model.findAndUpdate`, `Model.findOneAndUpdate`, `Model.findAndReplace`, `Model.findOneAndDelete`, `Model.delete`, `$inc`, `$set`, `UnsafeFilterError`, `UnsupportedUpdateOperationError`, `upsert`, `returnDocument`, `$setOnInsert`; "upsert a counter", "reserve quota atomically", "increment counter under concurrency", "bulk insert without N+1", "fast bulk insert", "insert thousands of rows", "atomic update without loading", "is atomic() safe with a request body filter"; 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
@@ -25,9 +25,44 @@ await User.atomic({ id: userId }, {
25
25
  });
26
26
  ```
27
27
 
28
- `Model.atomic(filter, operations)` → `Promise<number>`. Driver-flavored atomic mutation — MongoDB has `$set` / `$inc` / `$push` / `$pull`; the Postgres driver translates the equivalents. Use when you need to combine multiple field changes atomically without loading the model first.
28
+ `Model.atomic(filter, operations)` → `Promise<number>`. Driver-flavored atomic mutation — MongoDB has `$set` / `$inc` / `$push` / `$pull` natively; the Postgres driver supports `$set` / `$unset` / `$inc` / `$dec` (and `$setOnInsert` on upsert) and throws `UnsupportedUpdateOperationError` for the rest. Use when you need to combine multiple field changes atomically without loading the model first.
29
29
 
30
- **`filter` is sanitized like `where()`.** `atomic()`, `findAndUpdate()`, `findOneAndUpdate()`, `findAndReplace()` and `findOneAndDelete()` all run their `filter` argument through the same `$`-prefixed-key check as `Model.where()` (see [`query-data`](@warlock.js/cascade/query-data/SKILL.md)) and throw `UnsafeFilterError` on a key like `$ne`. Before this, these five bypassed `where()` entirely, so a filter forwarded straight from a request body — `User.atomic(req.body.filter, { $set: { role: "admin" } })` — could still smuggle an operator like `{ role: { $ne: "admin" } }` through as a live query even though `where()` itself was already guarded. Only the FILTER is checked — the update-operator object (`$set`/`$inc`/`$unset`/…) is untouched, since that's meant to carry `$` keys. If you legitimately need operator conditions in the filter, express them through `Model.query().where(...)` instead of the raw filter argument.
30
+ **`filter` is sanitized like `where()`.** `atomic()`, `findAndUpdate()`, `findOneAndUpdate()`, `findAndReplace()` and `findOneAndDelete()` all run their `filter` argument through the same `$`-prefixed-key check as `Model.where()` (see [`query-data`](@warlock.js/cascade/query-data/SKILL.md)) and throw `UnsafeFilterError` on a key like `$ne`. Before this, these five bypassed `where()` entirely, so a filter forwarded straight from a request body — `User.atomic(req.body.filter, { $set: { role: "admin" } })` — could still smuggle an operator like `{ role: { $ne: "admin" } }` through as a live query even though `where()` itself was already guarded. Only the FILTER is checked — the update-operator object (`$set`/`$inc`/`$unset`/…) is untouched, since that's meant to carry `$` keys. If you legitimately need operator conditions in the filter, express them through `Model.query().where(...)`, or pass `{ trustedFilter: true }` for a code-authored filter (see below).
31
+
32
+ ## Upsert, returnDocument, pipelines — the options argument
33
+
34
+ ```ts
35
+ // Counter: filter + $inc + $setOnInsert + upsert → the new document, one call
36
+ const counter = await Counter.findOneAndUpdate(
37
+ { key: "signups" },
38
+ { $inc: { count: 1 }, $setOnInsert: { startedAt: new Date() } },
39
+ { upsert: true }, // returnDocument defaults to "after"
40
+ );
41
+
42
+ // Quota reservation: the conditional filter needs trustedFilter (code-authored only!)
43
+ const granted = await Quota.atomic(
44
+ { id: quotaId, used: { $lt: 10 } },
45
+ { $inc: { used: 1 } },
46
+ { trustedFilter: true },
47
+ ); // 1 = reserved, 0 = quota full. 50 parallel calls → exactly 10 succeed
48
+
49
+ // MongoDB only: pipeline update, $addToSet, arrayFilters
50
+ await Post.findOneAndUpdate({ slug }, [{ $set: { score: { $add: ["$likes", "$shares"] } } }]);
51
+ await Post.atomic({ slug }, { $set: { "grades.$[low].score": 50 } }, { arrayFilters: [{ "low.score": { $lt: 50 } }] });
52
+ ```
53
+
54
+ - `atomic(filter, update, { upsert?, arrayFilters?, trustedFilter? })` → modified + upserted count.
55
+ - `findOneAndUpdate(filter, update, { ...same, returnDocument?: "before" | "after" })`.
56
+ - `findAndUpdate(filter, update, { upsert?, arrayFilters? })`. No `trustedFilter`, because it reads the rows back with `where(filter)`.
57
+
58
+ **Postgres.** An upsert runs as `INSERT … ON CONFLICT (target) DO UPDATE … RETURNING *`. The target is the primary key, or a unique index, whose columns are ALL equality keys of the filter. Other filter conditions become `DO UPDATE … WHERE`. If the row exists and fails them, nothing is written and `findOneAndUpdate` returns `null`. The driver throws `UnsupportedUpdateOperationError` (`.operation`, `.driver`) for:
59
+ - pipeline updates
60
+ - `arrayFilters`
61
+ - `$push`, `$pull`, `$addToSet` and unknown operators
62
+ - `returnDocument: "before"` together with `upsert`
63
+ - an upsert with no unique target it can find
64
+
65
+ It never ignores one of these without throwing.
31
66
 
32
67
  ## Bulk insert — `Model.createMany`
33
68
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: query-data
3
- description: '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`. Covers filter safety: `where()` rejects `$`-prefixed keys (`UnsafeFilterError`), `whereRaw()` string form rejects `$where`/`$function`/`$accumulator` (`UnsafeRawExpressionError`), and `whereLike`/`whereStartsWith`/`whereEndsWith`/`whereSearch` match string arguments literally (pass a `RegExp` for pattern semantics). Triggers: `.where`, `.find`, `.first`, `.firstOrFail`, `.all`, `.get`, `.orderBy`, `.exists`, `.whereIn`, `.whereBetween`, `.whereLike`, `.whereRaw`, `addScope`, `escapeRegex`, `likePatternToRegexSource`, `UnsafeFilterError`, `UnsafeRawExpressionError`; "filter by status", "find by id", "fetch active users", "check existence", "search box query", "is where() safe from injection"; 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`.'
3
+ description: 'Query records via the model — `.where(field, value)` / `.where(field, op, value)`, `.find(id)` / `.first` / `.all`, `.orderBy`, `.count` / `.exists`, `.lean()` plain-object reads, plus `.whereIn` / `.whereBetween` / `.whereLike` / `.pluck` / `.firstOrFail` / scopes via `addScope`. Covers filter safety: `where()` rejects `$`-prefixed keys (`UnsafeFilterError`), `whereRaw()` string form rejects `$where`/`$function`/`$accumulator` (`UnsafeRawExpressionError`), and `whereLike`/`whereStartsWith`/`whereEndsWith`/`whereSearch` match string arguments literally (pass a `RegExp` for pattern semantics). Triggers: `.where`, `.find`, `.first`, `.firstOrFail`, `.all`, `.get`, `.orderBy`, `.exists`, `.whereIn`, `.whereBetween`, `.whereLike`, `.whereRaw`, `.lean`, `UnsupportedLeanOperationError`, `addScope`, `escapeRegex`, `likePatternToRegexSource`, `UnsafeFilterError`, `UnsafeRawExpressionError`; "filter by status", "find by id", "fetch active users", "check existence", "search box query", "is where() safe from injection"; 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`.'
4
4
  ---
5
5
 
6
6
  # Query data
@@ -104,6 +104,15 @@ const newest = await User
104
104
 
105
105
  For pagination see [`@warlock.js/cascade/paginate-results/SKILL.md`](@warlock.js/cascade/paginate-results/SKILL.md).
106
106
 
107
+ ## Plain objects — `.lean()`
108
+
109
+ ```ts
110
+ const rows = await User.query().where("status", "active").lean().orderBy("id").get();
111
+ rows[0].name; // typed as UserSchema, not a User instance
112
+ ```
113
+
114
+ `.lean()` (anywhere before the terminator) returns the rows as the driver sent them: no `User` instances, no driver casting (a date string stays a string, Mongo `_id` stays an `ObjectId`), no `onFetched` / model `fetched` event. Use it for read-only lists and exports. It still strips `static hidden` fields. `where` / `select` / `orderBy` / `limit` / `first` / `paginate` behave as usual. Adding `.with()` or `.joinWith()` throws `UnsupportedLeanOperationError`: load relations with a second lean query, or drop `.lean()`. About 1.9x faster than a hydrated read for 10k Mongo documents (one run on a busy machine).
115
+
107
116
  ## Count and existence
108
117
 
109
118
  ```ts