@warlock.js/cascade 4.14.0 → 4.16.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.
- package/CHANGELOG.md +13 -0
- package/cjs/index.cjs +494 -72
- package/cjs/index.cjs.map +1 -1
- package/esm/contracts/query-builder.contract.d.mts +7 -3
- package/esm/contracts/query-builder.contract.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-builder.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-builder.mjs +4 -3
- package/esm/drivers/mongodb/mongodb-query-builder.mjs.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-parser.d.mts +21 -0
- package/esm/drivers/mongodb/mongodb-query-parser.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-parser.mjs +55 -22
- package/esm/drivers/mongodb/mongodb-query-parser.mjs.map +1 -1
- package/esm/errors/unsafe-filter.error.d.mts +33 -0
- package/esm/errors/unsafe-filter.error.d.mts.map +1 -0
- package/esm/errors/unsafe-filter.error.mjs +40 -0
- package/esm/errors/unsafe-filter.error.mjs.map +1 -0
- package/esm/errors/unsafe-raw-expression.error.d.mts +23 -0
- package/esm/errors/unsafe-raw-expression.error.d.mts.map +1 -0
- package/esm/errors/unsafe-raw-expression.error.mjs +28 -0
- package/esm/errors/unsafe-raw-expression.error.mjs.map +1 -0
- package/esm/index.d.mts +5 -1
- package/esm/index.mjs +5 -1
- package/esm/model/methods/accessor-methods.mjs +39 -1
- package/esm/model/methods/accessor-methods.mjs.map +1 -1
- package/esm/model/methods/delete-methods.mjs +3 -2
- package/esm/model/methods/delete-methods.mjs.map +1 -1
- package/esm/model/methods/query-methods.mjs +7 -4
- package/esm/model/methods/query-methods.mjs.map +1 -1
- package/esm/model/methods/serialization-methods.mjs +49 -4
- package/esm/model/methods/serialization-methods.mjs.map +1 -1
- package/esm/model/methods/write-methods.d.mts.map +1 -1
- package/esm/model/methods/write-methods.mjs +2 -4
- package/esm/model/methods/write-methods.mjs.map +1 -1
- package/esm/model/model.d.mts +47 -1
- package/esm/model/model.d.mts.map +1 -1
- package/esm/model/model.mjs +61 -2
- package/esm/model/model.mjs.map +1 -1
- package/esm/model/model.types.d.mts +1 -1
- package/esm/query-builder/query-builder.d.mts +13 -1
- package/esm/query-builder/query-builder.d.mts.map +1 -1
- package/esm/query-builder/query-builder.mjs +28 -11
- package/esm/query-builder/query-builder.mjs.map +1 -1
- package/esm/remover/database-remover.d.mts.map +1 -1
- package/esm/remover/database-remover.mjs +1 -1
- package/esm/remover/database-remover.mjs.map +1 -1
- package/esm/utils/escape-regex.d.mts +67 -0
- package/esm/utils/escape-regex.d.mts.map +1 -0
- package/esm/utils/escape-regex.mjs +76 -0
- package/esm/utils/escape-regex.mjs.map +1 -0
- package/esm/utils/sanitize-filter.d.mts +26 -0
- package/esm/utils/sanitize-filter.d.mts.map +1 -0
- package/esm/utils/sanitize-filter.mjs +76 -0
- package/esm/utils/sanitize-filter.mjs.map +1 -0
- package/esm/writer/database-writer.d.mts +12 -0
- package/esm/writer/database-writer.d.mts.map +1 -1
- package/esm/writer/database-writer.mjs +26 -6
- package/esm/writer/database-writer.mjs.map +1 -1
- package/llms-full.txt +59 -4
- package/llms.txt +3 -3
- package/package.json +24 -24
- package/skills/README.md +3 -3
- package/skills/define-model/SKILL.md +17 -1
- package/skills/perform-atomic-ops/SKILL.md +4 -1
- package/skills/query-data/SKILL.md +38 -2
package/llms-full.txt
CHANGED
|
@@ -920,7 +920,7 @@ See [`@warlock.js/cascade/subscribe-to-model-events/SKILL.md`](@warlock.js/casca
|
|
|
920
920
|
|
|
921
921
|
---
|
|
922
922
|
name: define-model
|
|
923
|
-
description: '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`.'
|
|
923
|
+
description: 'Define a Cascade model — `@RegisterModel()`, class extends `Model<TSchema>`, `static table`, `static schema`, three update idioms (`.set` / `.merge` / `.save`), `.unset`, `.destroy`, `static toJsonColumns` / `resource` / `static hidden` for output shaping. Covers write safety: `static hidden` fields are ALWAYS stripped from `toJSON()` regardless of `resource`/`toJsonColumns`, cascade warns once per model on an unhidden credential-shaped field, and `merge()` on a persisted model drops identity columns so request data can never retarget an `update()`/`destroy()` at another row. Triggers: `Model`, `RegisterModel`, `static schema`, `.set`, `.merge`, `.save`, `.unset`, `.destroy`, `toJsonColumns`, `resource`, `hidden`, `trustedPrimaryKey`; "how do I define a model", "shape the JSON output", "remove a field", "hide password from response", "prevent id in request body overwriting another row"; 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`.'
|
|
924
924
|
---
|
|
925
925
|
|
|
926
926
|
# Define a model
|
|
@@ -1014,6 +1014,8 @@ await user.merge({ name: "Augusta Ada King", status: "active" }).save();
|
|
|
1014
1014
|
|
|
1015
1015
|
The everyday case. Service takes `Partial<UserSchema>` from a request body, merges it into the instance, saves. Existing fields not in the object are untouched.
|
|
1016
1016
|
|
|
1017
|
+
**Safe against id-retargeting by design.** `model.merge(req.body); await model.save();` is the canonical update-my-profile shape — and `req.body` is attacker-controlled. On an already-persisted model, `merge()` drops identity columns (`id`, `_id`, the configured primary key) instead of applying them, so a body carrying `{ id: "<victim-id>", role: "admin" }` cannot redirect the write onto someone else's row: `update()`/`replace()`/`destroy()` build their filter from `model.trustedPrimaryKey` — the id captured when the instance became persisted (hydration, or right after an insert), never the current in-memory value. Identity columns are also excluded from the `$set`/`$unset` payload, so an explicit `.set("id", …)` on a loaded record doesn't rewrite the key of the row it's pinned to either. This only applies to an *existing* record — `User.create({ id, ... })` with an explicit id is unchanged, since there's no row to retarget yet. If you deliberately need to change a primary key, do it through the atomic/raw APIs (see [`perform-atomic-ops`](@warlock.js/cascade/perform-atomic-ops/SKILL.md)), not `merge()`.
|
|
1018
|
+
|
|
1017
1019
|
### `.save()` after manual mutation — when changes are spread
|
|
1018
1020
|
|
|
1019
1021
|
```ts
|
|
@@ -1093,11 +1095,25 @@ export class User extends Model<UserSchema> {
|
|
|
1093
1095
|
|
|
1094
1096
|
Plain TypeScript class. No framework dependencies. `static resourceColumns` narrows which columns reach the resource; pair the two for strongly-typed public output.
|
|
1095
1097
|
|
|
1098
|
+
### Fields that must never serialize — `static hidden`
|
|
1099
|
+
|
|
1100
|
+
```ts
|
|
1101
|
+
@RegisterModel()
|
|
1102
|
+
export class User extends Model<UserSchema> {
|
|
1103
|
+
public static table = "users";
|
|
1104
|
+
public static schema = userSchema;
|
|
1105
|
+
public static hidden = ["password", "resetToken"];
|
|
1106
|
+
}
|
|
1107
|
+
```
|
|
1108
|
+
|
|
1109
|
+
`static hidden: string[]` names top-level fields `toJSON()` (and therefore `JSON.stringify(user)` / `res.json(user)`) can **never** emit — stripped whether the model falls back to the raw-document default, uses `toJsonColumns`, or goes through `resource` (hidden wins over all three). Defaults to `[]`, so declaring it is opt-in and nothing changes on an existing model until you add fields — but because that default fails open, cascade logs a one-time `console.warn` per model whose schema declares a credential-shaped column (`password`, `passwordHash`, `secret`, `token`, `apiKey`/`api_key`, case-insensitive) that isn't covered by `hidden`, `resource`, or `toJsonColumns`. Treat that warning as "add this field to `hidden`," not noise to suppress. Prefer `hidden` over remembering to exclude the field from every `toJsonColumns` list by hand — it's enforced regardless of which shaping strategy a given model (or a future refactor) picks.
|
|
1110
|
+
|
|
1096
1111
|
## Things NOT to do
|
|
1097
1112
|
|
|
1098
1113
|
- Don't `new User()` to create a record — `User.create({...})` validates, persists, fires events.
|
|
1099
1114
|
- Don't `.set("relation_name", instance)` for a relation slot. Use `setRelation("name", instance)`.
|
|
1100
1115
|
- Don't return the raw model from an HTTP route without shaping output. Add `toJsonColumns` or `resource`.
|
|
1116
|
+
- Don't rely on `toJsonColumns`/`resource` alone to keep a password or token out of responses — declare it in `static hidden` too; that's the one path enforced no matter which shaping strategy is used, and the only one that survives someone adding a second `resource` later.
|
|
1101
1117
|
- Don't `await user.save()` and forget the `await` — your changes live only on the instance and never reach the DB.
|
|
1102
1118
|
- Don't expect schema defaults to apply on `.merge()` — defaults fire on `.create()` only.
|
|
1103
1119
|
|
|
@@ -1753,7 +1769,7 @@ The total count requires an extra query. On very large filtered tables, this can
|
|
|
1753
1769
|
|
|
1754
1770
|
---
|
|
1755
1771
|
name: perform-atomic-ops
|
|
1756
|
-
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`.'
|
|
1772
|
+
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`.'
|
|
1757
1773
|
---
|
|
1758
1774
|
|
|
1759
1775
|
# Use atomic operations
|
|
@@ -1780,6 +1796,8 @@ await User.atomic({ id: userId }, {
|
|
|
1780
1796
|
|
|
1781
1797
|
`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.
|
|
1782
1798
|
|
|
1799
|
+
**`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.
|
|
1800
|
+
|
|
1783
1801
|
## Bulk insert — `Model.createMany`
|
|
1784
1802
|
|
|
1785
1803
|
```ts
|
|
@@ -1864,6 +1882,7 @@ for (const user of targets) {
|
|
|
1864
1882
|
- 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.
|
|
1865
1883
|
- Don't hand-roll chunking around `createMany` — it already chunks by `batchSize` (default 500). Tune `batchSize` instead of slicing the array yourself.
|
|
1866
1884
|
- 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()`).
|
|
1885
|
+
- Don't forward a raw request-body object as the `filter` to `atomic()`/`findAndUpdate()`/`findOneAndUpdate()`/`findAndReplace()`/`findOneAndDelete()` expecting it to be un-sanitized — it isn't, `$`-prefixed keys throw `UnsafeFilterError` — but don't rely on that as your only authorization check either; it blocks operator injection, not "does this caller own this row."
|
|
1867
1886
|
|
|
1868
1887
|
## See also
|
|
1869
1888
|
|
|
@@ -1875,7 +1894,7 @@ for (const user of targets) {
|
|
|
1875
1894
|
|
|
1876
1895
|
---
|
|
1877
1896
|
name: query-data
|
|
1878
|
-
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`. 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`.'
|
|
1897
|
+
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`.'
|
|
1879
1898
|
---
|
|
1880
1899
|
|
|
1881
1900
|
# Query data
|
|
@@ -1921,6 +1940,23 @@ const targets = await User.where({ status: "active", role: "admin" }).get();
|
|
|
1921
1940
|
|
|
1922
1941
|
Equivalent to chained equalities. Useful when the filter comes from a dynamic source. **Object form only supports equality** — use chained `.where()` for operators.
|
|
1923
1942
|
|
|
1943
|
+
### Filters are safe to build from request data — but only through this API
|
|
1944
|
+
|
|
1945
|
+
`where(field, value)`, `where(field, "=", value)` and the object form all run the value through a `$`-prefixed-key check before it reaches the driver, so `User.first({ email: req.body.email, password: req.body.password })` throws `UnsafeFilterError` instead of compiling `{ password: { $ne: null } }` into a live MongoDB operator query (the classic NoSQL auth-bypass). This applies to every filter-accepting model static too — `first`, `findFirst`, `findAll`, `count`, `paginate`, `all`, `deleteMany`/`delete`, `deleteOne`, and the atomic/find-and-modify statics in [`perform-atomic-ops`](@warlock.js/cascade/perform-atomic-ops/SKILL.md). Explicit operator APIs (`where(field, operator, value)` for operators other than `=`, `whereIn`/`whereNull`/`whereBetween`/…, object-form `whereRaw({ ... })`) are intentionally not routed through this check — they already express an operator on purpose. Dotted paths (`"profile.name"`) and plain nested-object equality values remain valid; only literal `$`-prefixed keys are rejected. `sanitizeFilter` / `sanitizeFilterValue` are exported if you need the same check on a filter you forward to a driver-level API directly.
|
|
1946
|
+
|
|
1947
|
+
```ts
|
|
1948
|
+
await User.first({ email, password }); // throws UnsafeFilterError if password is `{ $ne: null }`
|
|
1949
|
+
```
|
|
1950
|
+
|
|
1951
|
+
## Raw expressions — `.whereRaw()` / `.orWhereRaw()`
|
|
1952
|
+
|
|
1953
|
+
```ts
|
|
1954
|
+
query.whereRaw({ $expr: { $gt: ["$stock", "$reserved"] } }); // object form — MongoDB
|
|
1955
|
+
query.whereRaw("age > ?", [30]); // string form — SQL, real bindings
|
|
1956
|
+
```
|
|
1957
|
+
|
|
1958
|
+
The **object form** is checked like any other filter value, plus it rejects the server-side-JavaScript operators `$where`, `$function` and `$accumulator` anywhere in the expression (throws `UnsafeRawExpressionError`); `$expr` and the other aggregation operators keep working. The **string form on the MongoDB driver throws `UnsafeRawExpressionError`** — a string used to compile to `{ $where: "<js>" }`, executing arbitrary JavaScript inside `mongod` per scanned document; there is no safe way to parameterize that, so it's rejected outright. Use the object form instead. SQL drivers keep string mode with real `?` bindings (unchanged).
|
|
1959
|
+
|
|
1924
1960
|
## Get one record
|
|
1925
1961
|
|
|
1926
1962
|
### By ID
|
|
@@ -1996,7 +2032,7 @@ Cascade's query builder has around 60 methods. Reach for these as the need arise
|
|
|
1996
2032
|
| `.whereNull(field)` / `.whereNotNull(field)` | Nullability checks |
|
|
1997
2033
|
| `.whereBetween(field, [a, b])` | Inclusive range |
|
|
1998
2034
|
| `.whereDate(field, value)`, `.whereDateBetween`, `.whereDateBefore`, `.whereDateAfter` | Date helpers |
|
|
1999
|
-
| `.whereLike(field, pattern)` / `.whereStartsWith` / `.whereEndsWith` | Pattern matching |
|
|
2035
|
+
| `.whereLike(field, pattern)` / `.whereStartsWith` / `.whereEndsWith` / `.whereSearch` | Pattern matching — see below for literal-vs-regex semantics |
|
|
2000
2036
|
| `.whereHas(relation, callback)` | Filter by conditions on a related model |
|
|
2001
2037
|
| `.sum(field)` / `.avg(field)` / `.min(field)` / `.max(field)` | Aggregates — [`use-aggregates`](@warlock.js/cascade/aggregate-data/SKILL.md) |
|
|
2002
2038
|
| `.distinct(field)` / `.pluck(field)` | Single-field reads (distinct values, flat list) |
|
|
@@ -2006,6 +2042,23 @@ Cascade's query builder has around 60 methods. Reach for these as the need arise
|
|
|
2006
2042
|
|
|
2007
2043
|
Each chains off `User.where(...)` or `User.query()` and ends with the appropriate terminator. (`where`, `with`, `joinWith`, `first`, `count`, `find`, `all`, `paginate` are static shortcuts on the model; the rest live on the query builder, so reach them via `User.query()` or by chaining off a static `where`.)
|
|
2008
2044
|
|
|
2045
|
+
### `whereLike` / `whereSearch` — string arguments match literally
|
|
2046
|
+
|
|
2047
|
+
```ts
|
|
2048
|
+
await User.where(...).whereLike("email", "%@gmail.com").get(); // literal, % still wildcards
|
|
2049
|
+
await User.query().whereSearch("name", req.query.q).get(); // safe with raw user input
|
|
2050
|
+
```
|
|
2051
|
+
|
|
2052
|
+
A **string** argument to `whereLike`/`whereNotLike`/`whereStartsWith`/`whereEndsWith` (and their `Not` variants) and the `$regex` form of `whereSearch` is escaped and matched as a **literal substring** — the SQL `LIKE` wildcard `%` still expands (to `.*`, runs of `%` collapse), but every other regex metacharacter (`.`, `*`, `+`, `^`, `$`, …) in the string is neutralized. This is what makes `whereSearch("name", req.query.q)` safe to wire directly to a search box: before this, a user-controlled string compiled straight into a MongoDB `$regex`, so `^.*$` matched everything, `^a`/`^b`/… probed a value back one character at a time, and a nested-quantifier pattern like `(a+)+$` triggered catastrophic backtracking (ReDoS) against every scanned document.
|
|
2053
|
+
|
|
2054
|
+
An explicit **`RegExp`** argument is still used as a pattern (verbatim, no escaping) — that's how you get real regex semantics — but a `RegExp` can't arrive as JSON, so it only reaches these calls when a developer constructs it in code. **Never build that `RegExp` from user input**; if you need dynamic pattern matching, escape the dynamic parts yourself with the exported `escapeRegex(value)` / `likePatternToRegexSource(pattern)` helpers.
|
|
2055
|
+
|
|
2056
|
+
```ts
|
|
2057
|
+
import { escapeRegex, likePatternToRegexSource } from "@warlock.js/cascade";
|
|
2058
|
+
```
|
|
2059
|
+
|
|
2060
|
+
The Postgres driver was already parameterized (`ILIKE $1`) and is unaffected either way.
|
|
2061
|
+
|
|
2009
2062
|
## Scopes — reusable query fragments
|
|
2010
2063
|
|
|
2011
2064
|
When you write the same `.where("status", "active")` across multiple services, define a scope on the model:
|
|
@@ -2035,6 +2088,8 @@ const activeUsers = await User.query().scope("active").get();
|
|
|
2035
2088
|
- Don't `Model.all()` without a filter on a production table — use pagination or chunking.
|
|
2036
2089
|
- Don't `!` away the null from `.find()` / `.first()` — handle the missing case explicitly or use `.firstOrFail()` when absence is a real error.
|
|
2037
2090
|
- Don't write the same filter chain across multiple services — promote it to a scope.
|
|
2091
|
+
- Don't build a `RegExp` for `whereLike`/`whereSearch` out of user input — a string argument is already safely escaped; a raw `RegExp` bypasses that and reintroduces the injection/ReDoS risk it closes.
|
|
2092
|
+
- Don't reach for `whereRaw()` string mode on MongoDB expecting it to still work — it now throws `UnsafeRawExpressionError`; use the object form (`whereRaw({ $expr: … })`).
|
|
2038
2093
|
|
|
2039
2094
|
## See also
|
|
2040
2095
|
|
package/llms.txt
CHANGED
|
@@ -10,13 +10,13 @@
|
|
|
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
|
-
- [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`.
|
|
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` / `static hidden` for output shaping. Covers write safety: `static hidden` fields are ALWAYS stripped from `toJSON()` regardless of `resource`/`toJsonColumns`, cascade warns once per model on an unhidden credential-shaped field, and `merge()` on a persisted model drops identity columns so request data can never retarget an `update()`/`destroy()` at another row. Triggers: `Model`, `RegisterModel`, `static schema`, `.set`, `.merge`, `.save`, `.unset`, `.destroy`, `toJsonColumns`, `resource`, `hidden`, `trustedPrimaryKey`; "how do I define a model", "shape the JSON output", "remove a field", "hide password from response", "prevent id in request body overwriting another row"; 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
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 row locking (`lockForUpdate({ skipLocked })` → `SELECT ... FOR UPDATE [SKIP LOCKED | NOWAIT]`, Postgres-only), 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, `lockForUpdate`, `skipLocked`, `FOR UPDATE`, `SKIP LOCKED`, `NOWAIT`, row lock, pessimistic lock, queue claim, `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", "lock rows so workers don't double-process", "claim jobs from a table", "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`, `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
|
-
- [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`.
|
|
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. `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`.
|
|
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`. 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`.
|
|
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`.
|
|
22
22
|
- [subscribe-to-model-events](@warlock.js/cascade/subscribe-to-model-events/SKILL.md): Hook into model lifecycle events — `saving` / `saved`, `creating` / `created`, `updating` / `updated`, `validating` / `validated`, `deleting` / `deleted`, `restoring` / `restored`, `fetching` / `fetched`. Per-model `Model.on(event, fn)` or global via `Model.globalEvents()`. Triggers: `Model.on`, `Model.off`, `saving`, `saved`, `created`, `updated`, `deleting`, `deleted`, `restored`; "audit log on save", "notify on change", "denormalize into search index"; typical import `import { Model } from "@warlock.js/cascade"`. Skip: dirty tracking — `@warlock.js/cascade/track-changes/SKILL.md`; competing libs `mongoose` middleware, `typeorm` subscribers, `prisma` extensions.
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@warlock.js/cascade",
|
|
3
3
|
"description": "ORM for managing databases",
|
|
4
|
+
"bin": {
|
|
5
|
+
"cascade": "bin/cascade.js"
|
|
6
|
+
},
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@mongez/copper": "^2.1.2",
|
|
9
|
+
"@mongez/dotenv": "^1.3.2",
|
|
10
|
+
"@mongez/events": "^2.2.7",
|
|
11
|
+
"@mongez/reinforcements": "^4.0.1",
|
|
12
|
+
"@mongez/supportive-is": "^2.1.4",
|
|
13
|
+
"@warlock.js/context": "4.16.0",
|
|
14
|
+
"@warlock.js/logger": "4.16.0",
|
|
15
|
+
"@warlock.js/seal": "4.16.0",
|
|
16
|
+
"citty": "^0.2.2",
|
|
17
|
+
"fast-glob": "^3.3.3"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/warlockjs/cascade"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"mongodb": "^5.7.0 || ^6.0.0 || ^7.0.0",
|
|
25
|
+
"pg": "^8.16.3"
|
|
26
|
+
},
|
|
4
27
|
"keywords": [
|
|
5
28
|
"mongodb",
|
|
6
29
|
"postgres",
|
|
@@ -17,30 +40,7 @@
|
|
|
17
40
|
],
|
|
18
41
|
"author": "hassanzohdy",
|
|
19
42
|
"license": "MIT",
|
|
20
|
-
"
|
|
21
|
-
"type": "git",
|
|
22
|
-
"url": "https://github.com/warlockjs/cascade"
|
|
23
|
-
},
|
|
24
|
-
"dependencies": {
|
|
25
|
-
"@mongez/copper": "^2.1.2",
|
|
26
|
-
"@mongez/dotenv": "^1.3.1",
|
|
27
|
-
"@mongez/events": "^2.2.6",
|
|
28
|
-
"@mongez/reinforcements": "^3.3.0",
|
|
29
|
-
"@mongez/supportive-is": "^2.1.3",
|
|
30
|
-
"@warlock.js/context": "4.14.0",
|
|
31
|
-
"@warlock.js/logger": "4.14.0",
|
|
32
|
-
"@warlock.js/seal": "4.14.0",
|
|
33
|
-
"citty": "^0.2.2",
|
|
34
|
-
"fast-glob": "^3.3.3"
|
|
35
|
-
},
|
|
36
|
-
"peerDependencies": {
|
|
37
|
-
"mongodb": "^5.7.0 || ^6.0.0 || ^7.0.0",
|
|
38
|
-
"pg": "^8.16.3"
|
|
39
|
-
},
|
|
40
|
-
"bin": {
|
|
41
|
-
"cascade": "bin/cascade.js"
|
|
42
|
-
},
|
|
43
|
-
"version": "4.14.0",
|
|
43
|
+
"version": "4.16.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
|
@@ -22,7 +22,7 @@ Pick the delete behavior — `permanent` (hard delete), `soft` (set deletedAt, k
|
|
|
22
22
|
|
|
23
23
|
### [`define-model/`](./define-model/SKILL.md)
|
|
24
24
|
|
|
25
|
-
Define a Cascade model — @RegisterModel() decorator, class extends Model<TSchema>, static table, static schema, three update idioms (.set / .merge / .save), .unset, .destroy, static toJsonColumns / resource for output shaping. Load when creating a model file, adding accessors,
|
|
25
|
+
Define a Cascade model — @RegisterModel() decorator, class extends Model<TSchema>, static table, static schema, three update idioms (.set / .merge / .save), .unset, .destroy, static toJsonColumns / resource / static hidden for output shaping. Covers why merge() on a persisted model can't retarget a write via a spoofed id, and why static hidden — not toJsonColumns/resource alone — is the field that's guaranteed to never serialize. Load when creating a model file, adding accessors, shaping serialized output, or hiding a password/token from responses.
|
|
26
26
|
|
|
27
27
|
### [`define-relations/`](./define-relations/SKILL.md)
|
|
28
28
|
|
|
@@ -42,11 +42,11 @@ 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(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.
|
|
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. atomic() / findAndUpdate() / findOneAndUpdate() / findAndReplace() / findOneAndDelete() sanitize their filter argument the same way where() does. 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
|
|
|
49
|
-
Query records via the model — .where(field, value) / .where(field, op, value), .find(id) / .first(filter?) / .all(filter?), .count / .exists, plus the broader query-builder vocabulary (.orderBy / .whereIn / .whereBetween / .whereLike / .pluck / .firstOrFail / scopes) via .query() or chained off .where(). Load when filtering, fetching by ID, getting a single match, or
|
|
49
|
+
Query records via the model — .where(field, value) / .where(field, op, value), .find(id) / .first(filter?) / .all(filter?), .count / .exists, plus the broader query-builder vocabulary (.orderBy / .whereIn / .whereBetween / .whereLike / .whereRaw / .pluck / .firstOrFail / scopes) via .query() or chained off .where(). Covers filter safety (where() rejects $-prefixed keys, whereRaw() string mode rejects $where, whereLike/whereSearch match strings literally). Load when filtering, fetching by ID, getting a single match, ordering, or checking whether a filter/search built from request data is safe.
|
|
50
50
|
|
|
51
51
|
### [`run-cascade-cli/`](./run-cascade-cli/SKILL.md)
|
|
52
52
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: define-model
|
|
3
|
-
description: '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`.'
|
|
3
|
+
description: 'Define a Cascade model — `@RegisterModel()`, class extends `Model<TSchema>`, `static table`, `static schema`, three update idioms (`.set` / `.merge` / `.save`), `.unset`, `.destroy`, `static toJsonColumns` / `resource` / `static hidden` for output shaping. Covers write safety: `static hidden` fields are ALWAYS stripped from `toJSON()` regardless of `resource`/`toJsonColumns`, cascade warns once per model on an unhidden credential-shaped field, and `merge()` on a persisted model drops identity columns so request data can never retarget an `update()`/`destroy()` at another row. Triggers: `Model`, `RegisterModel`, `static schema`, `.set`, `.merge`, `.save`, `.unset`, `.destroy`, `toJsonColumns`, `resource`, `hidden`, `trustedPrimaryKey`; "how do I define a model", "shape the JSON output", "remove a field", "hide password from response", "prevent id in request body overwriting another row"; 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`.'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Define a model
|
|
@@ -94,6 +94,8 @@ await user.merge({ name: "Augusta Ada King", status: "active" }).save();
|
|
|
94
94
|
|
|
95
95
|
The everyday case. Service takes `Partial<UserSchema>` from a request body, merges it into the instance, saves. Existing fields not in the object are untouched.
|
|
96
96
|
|
|
97
|
+
**Safe against id-retargeting by design.** `model.merge(req.body); await model.save();` is the canonical update-my-profile shape — and `req.body` is attacker-controlled. On an already-persisted model, `merge()` drops identity columns (`id`, `_id`, the configured primary key) instead of applying them, so a body carrying `{ id: "<victim-id>", role: "admin" }` cannot redirect the write onto someone else's row: `update()`/`replace()`/`destroy()` build their filter from `model.trustedPrimaryKey` — the id captured when the instance became persisted (hydration, or right after an insert), never the current in-memory value. Identity columns are also excluded from the `$set`/`$unset` payload, so an explicit `.set("id", …)` on a loaded record doesn't rewrite the key of the row it's pinned to either. This only applies to an *existing* record — `User.create({ id, ... })` with an explicit id is unchanged, since there's no row to retarget yet. If you deliberately need to change a primary key, do it through the atomic/raw APIs (see [`perform-atomic-ops`](@warlock.js/cascade/perform-atomic-ops/SKILL.md)), not `merge()`.
|
|
98
|
+
|
|
97
99
|
### `.save()` after manual mutation — when changes are spread
|
|
98
100
|
|
|
99
101
|
```ts
|
|
@@ -173,11 +175,25 @@ export class User extends Model<UserSchema> {
|
|
|
173
175
|
|
|
174
176
|
Plain TypeScript class. No framework dependencies. `static resourceColumns` narrows which columns reach the resource; pair the two for strongly-typed public output.
|
|
175
177
|
|
|
178
|
+
### Fields that must never serialize — `static hidden`
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
@RegisterModel()
|
|
182
|
+
export class User extends Model<UserSchema> {
|
|
183
|
+
public static table = "users";
|
|
184
|
+
public static schema = userSchema;
|
|
185
|
+
public static hidden = ["password", "resetToken"];
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`static hidden: string[]` names top-level fields `toJSON()` (and therefore `JSON.stringify(user)` / `res.json(user)`) can **never** emit — stripped whether the model falls back to the raw-document default, uses `toJsonColumns`, or goes through `resource` (hidden wins over all three). Defaults to `[]`, so declaring it is opt-in and nothing changes on an existing model until you add fields — but because that default fails open, cascade logs a one-time `console.warn` per model whose schema declares a credential-shaped column (`password`, `passwordHash`, `secret`, `token`, `apiKey`/`api_key`, case-insensitive) that isn't covered by `hidden`, `resource`, or `toJsonColumns`. Treat that warning as "add this field to `hidden`," not noise to suppress. Prefer `hidden` over remembering to exclude the field from every `toJsonColumns` list by hand — it's enforced regardless of which shaping strategy a given model (or a future refactor) picks.
|
|
190
|
+
|
|
176
191
|
## Things NOT to do
|
|
177
192
|
|
|
178
193
|
- Don't `new User()` to create a record — `User.create({...})` validates, persists, fires events.
|
|
179
194
|
- Don't `.set("relation_name", instance)` for a relation slot. Use `setRelation("name", instance)`.
|
|
180
195
|
- Don't return the raw model from an HTTP route without shaping output. Add `toJsonColumns` or `resource`.
|
|
196
|
+
- Don't rely on `toJsonColumns`/`resource` alone to keep a password or token out of responses — declare it in `static hidden` too; that's the one path enforced no matter which shaping strategy is used, and the only one that survives someone adding a second `resource` later.
|
|
181
197
|
- Don't `await user.save()` and forget the `await` — your changes live only on the instance and never reach the DB.
|
|
182
198
|
- Don't expect schema defaults to apply on `.merge()` — defaults fire on `.create()` only.
|
|
183
199
|
|
|
@@ -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`, `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`.'
|
|
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`.'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Use atomic operations
|
|
@@ -27,6 +27,8 @@ await User.atomic({ id: userId }, {
|
|
|
27
27
|
|
|
28
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.
|
|
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.
|
|
31
|
+
|
|
30
32
|
## Bulk insert — `Model.createMany`
|
|
31
33
|
|
|
32
34
|
```ts
|
|
@@ -111,6 +113,7 @@ for (const user of targets) {
|
|
|
111
113
|
- 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.
|
|
112
114
|
- Don't hand-roll chunking around `createMany` — it already chunks by `batchSize` (default 500). Tune `batchSize` instead of slicing the array yourself.
|
|
113
115
|
- 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()`).
|
|
116
|
+
- Don't forward a raw request-body object as the `filter` to `atomic()`/`findAndUpdate()`/`findOneAndUpdate()`/`findAndReplace()`/`findOneAndDelete()` expecting it to be un-sanitized — it isn't, `$`-prefixed keys throw `UnsafeFilterError` — but don't rely on that as your only authorization check either; it blocks operator injection, not "does this caller own this row."
|
|
114
117
|
|
|
115
118
|
## See also
|
|
116
119
|
|
|
@@ -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`. 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`.'
|
|
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`.'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Query data
|
|
@@ -46,6 +46,23 @@ const targets = await User.where({ status: "active", role: "admin" }).get();
|
|
|
46
46
|
|
|
47
47
|
Equivalent to chained equalities. Useful when the filter comes from a dynamic source. **Object form only supports equality** — use chained `.where()` for operators.
|
|
48
48
|
|
|
49
|
+
### Filters are safe to build from request data — but only through this API
|
|
50
|
+
|
|
51
|
+
`where(field, value)`, `where(field, "=", value)` and the object form all run the value through a `$`-prefixed-key check before it reaches the driver, so `User.first({ email: req.body.email, password: req.body.password })` throws `UnsafeFilterError` instead of compiling `{ password: { $ne: null } }` into a live MongoDB operator query (the classic NoSQL auth-bypass). This applies to every filter-accepting model static too — `first`, `findFirst`, `findAll`, `count`, `paginate`, `all`, `deleteMany`/`delete`, `deleteOne`, and the atomic/find-and-modify statics in [`perform-atomic-ops`](@warlock.js/cascade/perform-atomic-ops/SKILL.md). Explicit operator APIs (`where(field, operator, value)` for operators other than `=`, `whereIn`/`whereNull`/`whereBetween`/…, object-form `whereRaw({ ... })`) are intentionally not routed through this check — they already express an operator on purpose. Dotted paths (`"profile.name"`) and plain nested-object equality values remain valid; only literal `$`-prefixed keys are rejected. `sanitizeFilter` / `sanitizeFilterValue` are exported if you need the same check on a filter you forward to a driver-level API directly.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
await User.first({ email, password }); // throws UnsafeFilterError if password is `{ $ne: null }`
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Raw expressions — `.whereRaw()` / `.orWhereRaw()`
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
query.whereRaw({ $expr: { $gt: ["$stock", "$reserved"] } }); // object form — MongoDB
|
|
61
|
+
query.whereRaw("age > ?", [30]); // string form — SQL, real bindings
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The **object form** is checked like any other filter value, plus it rejects the server-side-JavaScript operators `$where`, `$function` and `$accumulator` anywhere in the expression (throws `UnsafeRawExpressionError`); `$expr` and the other aggregation operators keep working. The **string form on the MongoDB driver throws `UnsafeRawExpressionError`** — a string used to compile to `{ $where: "<js>" }`, executing arbitrary JavaScript inside `mongod` per scanned document; there is no safe way to parameterize that, so it's rejected outright. Use the object form instead. SQL drivers keep string mode with real `?` bindings (unchanged).
|
|
65
|
+
|
|
49
66
|
## Get one record
|
|
50
67
|
|
|
51
68
|
### By ID
|
|
@@ -121,7 +138,7 @@ Cascade's query builder has around 60 methods. Reach for these as the need arise
|
|
|
121
138
|
| `.whereNull(field)` / `.whereNotNull(field)` | Nullability checks |
|
|
122
139
|
| `.whereBetween(field, [a, b])` | Inclusive range |
|
|
123
140
|
| `.whereDate(field, value)`, `.whereDateBetween`, `.whereDateBefore`, `.whereDateAfter` | Date helpers |
|
|
124
|
-
| `.whereLike(field, pattern)` / `.whereStartsWith` / `.whereEndsWith` | Pattern matching |
|
|
141
|
+
| `.whereLike(field, pattern)` / `.whereStartsWith` / `.whereEndsWith` / `.whereSearch` | Pattern matching — see below for literal-vs-regex semantics |
|
|
125
142
|
| `.whereHas(relation, callback)` | Filter by conditions on a related model |
|
|
126
143
|
| `.sum(field)` / `.avg(field)` / `.min(field)` / `.max(field)` | Aggregates — [`use-aggregates`](@warlock.js/cascade/aggregate-data/SKILL.md) |
|
|
127
144
|
| `.distinct(field)` / `.pluck(field)` | Single-field reads (distinct values, flat list) |
|
|
@@ -131,6 +148,23 @@ Cascade's query builder has around 60 methods. Reach for these as the need arise
|
|
|
131
148
|
|
|
132
149
|
Each chains off `User.where(...)` or `User.query()` and ends with the appropriate terminator. (`where`, `with`, `joinWith`, `first`, `count`, `find`, `all`, `paginate` are static shortcuts on the model; the rest live on the query builder, so reach them via `User.query()` or by chaining off a static `where`.)
|
|
133
150
|
|
|
151
|
+
### `whereLike` / `whereSearch` — string arguments match literally
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
await User.where(...).whereLike("email", "%@gmail.com").get(); // literal, % still wildcards
|
|
155
|
+
await User.query().whereSearch("name", req.query.q).get(); // safe with raw user input
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
A **string** argument to `whereLike`/`whereNotLike`/`whereStartsWith`/`whereEndsWith` (and their `Not` variants) and the `$regex` form of `whereSearch` is escaped and matched as a **literal substring** — the SQL `LIKE` wildcard `%` still expands (to `.*`, runs of `%` collapse), but every other regex metacharacter (`.`, `*`, `+`, `^`, `$`, …) in the string is neutralized. This is what makes `whereSearch("name", req.query.q)` safe to wire directly to a search box: before this, a user-controlled string compiled straight into a MongoDB `$regex`, so `^.*$` matched everything, `^a`/`^b`/… probed a value back one character at a time, and a nested-quantifier pattern like `(a+)+$` triggered catastrophic backtracking (ReDoS) against every scanned document.
|
|
159
|
+
|
|
160
|
+
An explicit **`RegExp`** argument is still used as a pattern (verbatim, no escaping) — that's how you get real regex semantics — but a `RegExp` can't arrive as JSON, so it only reaches these calls when a developer constructs it in code. **Never build that `RegExp` from user input**; if you need dynamic pattern matching, escape the dynamic parts yourself with the exported `escapeRegex(value)` / `likePatternToRegexSource(pattern)` helpers.
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
import { escapeRegex, likePatternToRegexSource } from "@warlock.js/cascade";
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The Postgres driver was already parameterized (`ILIKE $1`) and is unaffected either way.
|
|
167
|
+
|
|
134
168
|
## Scopes — reusable query fragments
|
|
135
169
|
|
|
136
170
|
When you write the same `.where("status", "active")` across multiple services, define a scope on the model:
|
|
@@ -160,6 +194,8 @@ const activeUsers = await User.query().scope("active").get();
|
|
|
160
194
|
- Don't `Model.all()` without a filter on a production table — use pagination or chunking.
|
|
161
195
|
- Don't `!` away the null from `.find()` / `.first()` — handle the missing case explicitly or use `.firstOrFail()` when absence is a real error.
|
|
162
196
|
- Don't write the same filter chain across multiple services — promote it to a scope.
|
|
197
|
+
- Don't build a `RegExp` for `whereLike`/`whereSearch` out of user input — a string argument is already safely escaped; a raw `RegExp` bypasses that and reintroduces the injection/ReDoS risk it closes.
|
|
198
|
+
- Don't reach for `whereRaw()` string mode on MongoDB expecting it to still work — it now throws `UnsafeRawExpressionError`; use the object form (`whereRaw({ $expr: … })`).
|
|
163
199
|
|
|
164
200
|
## See also
|
|
165
201
|
|