@warlock.js/cascade 5.24.0 → 5.25.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 (70) hide show
  1. package/CHANGELOG.md +18 -5
  2. package/README.md +101 -101
  3. package/bin/cascade.js +0 -0
  4. package/cjs/index.cjs +201 -71
  5. package/cjs/index.cjs.map +1 -1
  6. package/esm/contracts/database-driver.contract.d.mts +22 -1
  7. package/esm/contracts/database-driver.contract.d.mts.map +1 -1
  8. package/esm/contracts/index.d.mts +1 -1
  9. package/esm/data-source/data-source-registry.d.mts +8 -6
  10. package/esm/data-source/data-source-registry.d.mts.map +1 -1
  11. package/esm/data-source/data-source-registry.mjs +7 -1
  12. package/esm/data-source/data-source-registry.mjs.map +1 -1
  13. package/esm/drivers/mongodb/mongodb-driver.d.mts +13 -0
  14. package/esm/drivers/mongodb/mongodb-driver.d.mts.map +1 -1
  15. package/esm/drivers/mongodb/mongodb-driver.mjs +71 -2
  16. package/esm/drivers/mongodb/mongodb-driver.mjs.map +1 -1
  17. package/esm/drivers/mongodb/mongodb-query-builder.d.mts.map +1 -1
  18. package/esm/drivers/mongodb/mongodb-query-builder.mjs +2 -1
  19. package/esm/drivers/mongodb/mongodb-query-builder.mjs.map +1 -1
  20. package/esm/drivers/postgres/postgres-driver.d.mts +9 -0
  21. package/esm/drivers/postgres/postgres-driver.d.mts.map +1 -1
  22. package/esm/drivers/postgres/postgres-driver.mjs +55 -7
  23. package/esm/drivers/postgres/postgres-driver.mjs.map +1 -1
  24. package/esm/drivers/postgres/postgres-query-parser.d.mts +1 -4
  25. package/esm/drivers/postgres/postgres-query-parser.d.mts.map +1 -1
  26. package/esm/drivers/postgres/postgres-query-parser.mjs +4 -5
  27. package/esm/drivers/postgres/postgres-query-parser.mjs.map +1 -1
  28. package/esm/errors/unsafe-filter.error.mjs.map +1 -1
  29. package/esm/errors/unsafe-raw-expression.error.mjs.map +1 -1
  30. package/esm/errors/unsupported-lock-for-update.error.d.mts +11 -0
  31. package/esm/errors/unsupported-lock-for-update.error.d.mts.map +1 -0
  32. package/esm/errors/unsupported-lock-for-update.error.mjs +16 -0
  33. package/esm/errors/unsupported-lock-for-update.error.mjs.map +1 -0
  34. package/esm/events/model-events.mjs.map +1 -1
  35. package/esm/index.d.mts +3 -2
  36. package/esm/index.mjs +2 -1
  37. package/esm/migration/migration-runner.mjs.map +1 -1
  38. package/esm/migration/sql-grammar.mjs.map +1 -1
  39. package/esm/model/generate-model-id.mjs +23 -0
  40. package/esm/model/generate-model-id.mjs.map +1 -0
  41. package/esm/model/methods/delete-methods.mjs.map +1 -1
  42. package/esm/model/methods/meta-methods.mjs.map +1 -1
  43. package/esm/model/methods/query-methods.mjs +25 -2
  44. package/esm/model/methods/query-methods.mjs.map +1 -1
  45. package/esm/model/methods/write-methods.mjs.map +1 -1
  46. package/esm/query-builder/query-builder.mjs +1 -1
  47. package/esm/query-builder/query-builder.mjs.map +1 -1
  48. package/esm/utils/sanitize-filter.mjs.map +1 -1
  49. package/esm/validation/plugins/database-rules-plugin.mjs.map +1 -1
  50. package/esm/writer/database-writer.d.mts +0 -33
  51. package/esm/writer/database-writer.d.mts.map +1 -1
  52. package/esm/writer/database-writer.mjs +3 -52
  53. package/esm/writer/database-writer.mjs.map +1 -1
  54. package/llms-full.txt +2901 -2864
  55. package/package.json +5 -5
  56. package/skills/aggregate-data/SKILL.md +165 -165
  57. package/skills/alter-migration/SKILL.md +531 -531
  58. package/skills/configure-delete-strategy/SKILL.md +130 -130
  59. package/skills/define-model/SKILL.md +205 -205
  60. package/skills/define-relations/SKILL.md +171 -171
  61. package/skills/manage-data-sources/SKILL.md +177 -140
  62. package/skills/manage-transactions/SKILL.md +215 -205
  63. package/skills/paginate-results/SKILL.md +122 -122
  64. package/skills/perform-atomic-ops/SKILL.md +156 -156
  65. package/skills/query-data/SKILL.md +213 -213
  66. package/skills/run-cascade-cli/SKILL.md +125 -125
  67. package/skills/search-by-vector/SKILL.md +127 -127
  68. package/skills/subscribe-to-model-events/SKILL.md +148 -148
  69. package/skills/track-changes/SKILL.md +127 -127
  70. package/skills/write-migration/SKILL.md +206 -206
@@ -1,205 +1,205 @@
1
- ---
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` / `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
- ---
5
-
6
- # Define a model
7
-
8
- Four moves: schema → class → write → read. Every Cascade model uses the same shape.
9
-
10
- ## Step 1 — Define the schema
11
-
12
- The schema is your single declaration of what a record looks like. Same `v.object` does triple duty later: validates incoming data, infers the TypeScript type, and is the shape your table writes against.
13
-
14
- ```ts title="src/app/users/models/user/user.model.ts"
15
- import { v, type Infer } from "@warlock.js/seal";
16
-
17
- export const userSchema = v.object({
18
- name: v.string(),
19
- email: v.string().email(),
20
- status: v.literal("active", "inactive").default("active"),
21
- });
22
-
23
- export type UserSchema = Infer<typeof userSchema>;
24
- ```
25
-
26
- - Fields are **required by default** — chain `.optional()` on any field that may be missing.
27
- - `Infer<typeof userSchema>` derives the TS type. No second declaration, no drift.
28
- - The schema is standalone — reuse it for HTTP body validation, service-input validation, anywhere else.
29
- - See [`@warlock.js/seal/seal-basics/SKILL.md`](@warlock.js/seal/seal-basics/SKILL.md) for the validator vocabulary.
30
-
31
- ## Step 2 — Define the model class
32
-
33
- ```ts
34
- import { Model, RegisterModel } from "@warlock.js/cascade";
35
-
36
- @RegisterModel()
37
- export class User extends Model<UserSchema> {
38
- public static table = "users";
39
- public static schema = userSchema;
40
- }
41
- ```
42
-
43
- - `@RegisterModel()` puts `User` in the global registry. That registry is what lets relations look each other up by name.
44
- - `extends Model<UserSchema>` gives the class the entire CRUD/query API typed against your schema.
45
- - `static table` matches the migration. Plural, lowercase, snake_case is the convention on both drivers.
46
- - `static schema` attaches the validator. On every `save()`, the data goes through `userSchema` before it hits the database.
47
-
48
- ## Read state — `.id`, `.uuid`, and `.get(field)`
49
-
50
- ```ts
51
- user.id; // direct property — ID is so common cascade exposes it directly
52
- user.uuid; // the same id, narrowed to `string` (see below)
53
- user.get("status"); // canonical reader for every other column
54
- user.get<number>("age"); // TypeScript generic for typed reads
55
- ```
56
-
57
- Use the direct `.id` property; use `.get("field")` for everything else. Add a typed getter on the model class when the same field is read in many places — turns N typed-cast call sites into one named accessor.
58
-
59
- ### `.id` vs `.uuid` — a TypeScript convenience, same value
60
-
61
- Both getters return the model's primary id (`this.get("id")`); the only difference is the **static type**:
62
-
63
- | Accessor | Return type | Use when |
64
- |---|---|---|
65
- | `model.id` | `string \| number` | You want the real id type — SQL auto-increment is `number`, MongoDB's ObjectId is `string`. |
66
- | `model.uuid` | `string` | You want to pass the id to code typed for a single `string` id, without leaking the engine's `string \| number` union everywhere. |
67
-
68
- `uuid` does **not** validate or coerce the value to a UUID — the name is historical. It simply returns the same id, typed as `string`, so a helper typed for a string id accepts it on either engine:
69
-
70
- ```ts
71
- function shareLink(modelId: string) { /* ... */ }
72
-
73
- shareLink(user.uuid); // ✓ typechecks on both Postgres and MongoDB
74
- shareLink(user.id); // ✗ TS error on SQL: `number` is not assignable to `string`
75
- ```
76
-
77
- ## Write — three update idioms
78
-
79
- Cascade gives you three ways to update an instance. Knowing when each fits saves you from reaching for the wrong one.
80
-
81
- ### `.set(field, value).save()` — one field change
82
-
83
- ```ts
84
- await user.set("status", "inactive").save();
85
- ```
86
-
87
- Direct, reads top to bottom. `.set()` stages; `.save()` persists and fires events.
88
-
89
- ### `.merge(data).save()` — bulk update from an object
90
-
91
- ```ts
92
- await user.merge({ name: "Augusta Ada King", status: "active" }).save();
93
- ```
94
-
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
-
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
-
99
- ### `.save()` after manual mutation — when changes are spread
100
-
101
- ```ts
102
- user.set("status", "inactive");
103
-
104
- if (someCondition) {
105
- user.set("online_state", "offline");
106
- }
107
-
108
- await user.save();
109
- ```
110
-
111
- Quick reference:
112
-
113
- | Situation | Pattern |
114
- | --- | --- |
115
- | 1–2 specific fields | `user.set(k, v).save()` |
116
- | Bulk from an object | `user.merge(data).save()` |
117
- | After spread mutations | `user.save()` |
118
-
119
- ## Remove a field — `.unset(field)`
120
-
121
- ```ts
122
- await user.unset("image").save();
123
- ```
124
-
125
- `.unset()` marks the field for removal — Postgres sets it to `NULL`, MongoDB drops the field entirely. Different from `.set("image", null)` which stores an explicit null (and may fail validation if the field isn't `.optional()`).
126
-
127
- ## Delete — `.destroy()`
128
-
129
- ```ts
130
- await user.destroy();
131
- ```
132
-
133
- Runs the model's lifecycle (events, configured delete strategy), then removes the record. See [`@warlock.js/cascade/configure-delete-strategy/SKILL.md`](@warlock.js/cascade/configure-delete-strategy/SKILL.md) for soft / hard / trash semantics.
134
-
135
- ## Public output shaping
136
-
137
- `JSON.stringify(user)` calls `model.toJSON()` under the hood. With no configuration, it returns the entire row. **Always configure shaping when the model is returned from an HTTP handler.**
138
-
139
- ### Fast escape — `static toJsonColumns`
140
-
141
- ```ts
142
- @RegisterModel()
143
- export class User extends Model<UserSchema> {
144
- public static table = "users";
145
- public static schema = userSchema;
146
- public static toJsonColumns = ["id", "name", "email"];
147
- }
148
- ```
149
-
150
- Anything outside the allow-list is dropped from serialization. Use when the public shape is a strict subset of the columns.
151
-
152
- ### Richer path — `static resource`
153
-
154
- ```ts
155
- class UserResource {
156
- public constructor(private data: Record<string, unknown>) {}
157
-
158
- public toJSON() {
159
- return {
160
- id: this.data.id,
161
- displayName: this.data.name,
162
- contactEmail: this.data.email,
163
- avatar: this.data.image ?? null,
164
- };
165
- }
166
- }
167
-
168
- @RegisterModel()
169
- export class User extends Model<UserSchema> {
170
- public static table = "users";
171
- public static schema = userSchema;
172
- public static resource = UserResource;
173
- }
174
- ```
175
-
176
- Plain TypeScript class. No framework dependencies. `static resourceColumns` narrows which columns reach the resource; pair the two for strongly-typed public output.
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
-
191
- ## Things NOT to do
192
-
193
- - Don't `new User()` to create a record — `User.create({...})` validates, persists, fires events.
194
- - Don't `.set("relation_name", instance)` for a relation slot. Use `setRelation("name", instance)`.
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.
197
- - Don't `await user.save()` and forget the `await` — your changes live only on the instance and never reach the DB.
198
- - Don't expect schema defaults to apply on `.merge()` — defaults fire on `.create()` only.
199
-
200
- ## See also
201
-
202
- - [`@warlock.js/cascade/query-data/SKILL.md`](@warlock.js/cascade/query-data/SKILL.md) — finding and filtering records
203
- - [`@warlock.js/cascade/define-relations/SKILL.md`](@warlock.js/cascade/define-relations/SKILL.md) — relations and eager loading
204
- - [`@warlock.js/cascade/track-changes/SKILL.md`](@warlock.js/cascade/track-changes/SKILL.md) — dirty tracking
205
- - [`@warlock.js/cascade/subscribe-to-model-events/SKILL.md`](@warlock.js/cascade/subscribe-to-model-events/SKILL.md) — lifecycle hooks
1
+ ---
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` / `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
+ ---
5
+
6
+ # Define a model
7
+
8
+ Four moves: schema → class → write → read. Every Cascade model uses the same shape.
9
+
10
+ ## Step 1 — Define the schema
11
+
12
+ The schema is your single declaration of what a record looks like. Same `v.object` does triple duty later: validates incoming data, infers the TypeScript type, and is the shape your table writes against.
13
+
14
+ ```ts title="src/app/users/models/user/user.model.ts"
15
+ import { v, type Infer } from "@warlock.js/seal";
16
+
17
+ export const userSchema = v.object({
18
+ name: v.string(),
19
+ email: v.string().email(),
20
+ status: v.literal("active", "inactive").default("active"),
21
+ });
22
+
23
+ export type UserSchema = Infer<typeof userSchema>;
24
+ ```
25
+
26
+ - Fields are **required by default** — chain `.optional()` on any field that may be missing.
27
+ - `Infer<typeof userSchema>` derives the TS type. No second declaration, no drift.
28
+ - The schema is standalone — reuse it for HTTP body validation, service-input validation, anywhere else.
29
+ - See [`@warlock.js/seal/seal-basics/SKILL.md`](@warlock.js/seal/seal-basics/SKILL.md) for the validator vocabulary.
30
+
31
+ ## Step 2 — Define the model class
32
+
33
+ ```ts
34
+ import { Model, RegisterModel } from "@warlock.js/cascade";
35
+
36
+ @RegisterModel()
37
+ export class User extends Model<UserSchema> {
38
+ public static table = "users";
39
+ public static schema = userSchema;
40
+ }
41
+ ```
42
+
43
+ - `@RegisterModel()` puts `User` in the global registry. That registry is what lets relations look each other up by name.
44
+ - `extends Model<UserSchema>` gives the class the entire CRUD/query API typed against your schema.
45
+ - `static table` matches the migration. Plural, lowercase, snake_case is the convention on both drivers.
46
+ - `static schema` attaches the validator. On every `save()`, the data goes through `userSchema` before it hits the database.
47
+
48
+ ## Read state — `.id`, `.uuid`, and `.get(field)`
49
+
50
+ ```ts
51
+ user.id; // direct property — ID is so common cascade exposes it directly
52
+ user.uuid; // the same id, narrowed to `string` (see below)
53
+ user.get("status"); // canonical reader for every other column
54
+ user.get<number>("age"); // TypeScript generic for typed reads
55
+ ```
56
+
57
+ Use the direct `.id` property; use `.get("field")` for everything else. Add a typed getter on the model class when the same field is read in many places — turns N typed-cast call sites into one named accessor.
58
+
59
+ ### `.id` vs `.uuid` — a TypeScript convenience, same value
60
+
61
+ Both getters return the model's primary id (`this.get("id")`); the only difference is the **static type**:
62
+
63
+ | Accessor | Return type | Use when |
64
+ |---|---|---|
65
+ | `model.id` | `string \| number` | You want the real id type — SQL auto-increment is `number`, MongoDB's ObjectId is `string`. |
66
+ | `model.uuid` | `string` | You want to pass the id to code typed for a single `string` id, without leaking the engine's `string \| number` union everywhere. |
67
+
68
+ `uuid` does **not** validate or coerce the value to a UUID — the name is historical. It simply returns the same id, typed as `string`, so a helper typed for a string id accepts it on either engine:
69
+
70
+ ```ts
71
+ function shareLink(modelId: string) { /* ... */ }
72
+
73
+ shareLink(user.uuid); // ✓ typechecks on both Postgres and MongoDB
74
+ shareLink(user.id); // ✗ TS error on SQL: `number` is not assignable to `string`
75
+ ```
76
+
77
+ ## Write — three update idioms
78
+
79
+ Cascade gives you three ways to update an instance. Knowing when each fits saves you from reaching for the wrong one.
80
+
81
+ ### `.set(field, value).save()` — one field change
82
+
83
+ ```ts
84
+ await user.set("status", "inactive").save();
85
+ ```
86
+
87
+ Direct, reads top to bottom. `.set()` stages; `.save()` persists and fires events.
88
+
89
+ ### `.merge(data).save()` — bulk update from an object
90
+
91
+ ```ts
92
+ await user.merge({ name: "Augusta Ada King", status: "active" }).save();
93
+ ```
94
+
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
+
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
+
99
+ ### `.save()` after manual mutation — when changes are spread
100
+
101
+ ```ts
102
+ user.set("status", "inactive");
103
+
104
+ if (someCondition) {
105
+ user.set("online_state", "offline");
106
+ }
107
+
108
+ await user.save();
109
+ ```
110
+
111
+ Quick reference:
112
+
113
+ | Situation | Pattern |
114
+ | --- | --- |
115
+ | 1–2 specific fields | `user.set(k, v).save()` |
116
+ | Bulk from an object | `user.merge(data).save()` |
117
+ | After spread mutations | `user.save()` |
118
+
119
+ ## Remove a field — `.unset(field)`
120
+
121
+ ```ts
122
+ await user.unset("image").save();
123
+ ```
124
+
125
+ `.unset()` marks the field for removal — Postgres sets it to `NULL`, MongoDB drops the field entirely. Different from `.set("image", null)` which stores an explicit null (and may fail validation if the field isn't `.optional()`).
126
+
127
+ ## Delete — `.destroy()`
128
+
129
+ ```ts
130
+ await user.destroy();
131
+ ```
132
+
133
+ Runs the model's lifecycle (events, configured delete strategy), then removes the record. See [`@warlock.js/cascade/configure-delete-strategy/SKILL.md`](@warlock.js/cascade/configure-delete-strategy/SKILL.md) for soft / hard / trash semantics.
134
+
135
+ ## Public output shaping
136
+
137
+ `JSON.stringify(user)` calls `model.toJSON()` under the hood. With no configuration, it returns the entire row. **Always configure shaping when the model is returned from an HTTP handler.**
138
+
139
+ ### Fast escape — `static toJsonColumns`
140
+
141
+ ```ts
142
+ @RegisterModel()
143
+ export class User extends Model<UserSchema> {
144
+ public static table = "users";
145
+ public static schema = userSchema;
146
+ public static toJsonColumns = ["id", "name", "email"];
147
+ }
148
+ ```
149
+
150
+ Anything outside the allow-list is dropped from serialization. Use when the public shape is a strict subset of the columns.
151
+
152
+ ### Richer path — `static resource`
153
+
154
+ ```ts
155
+ class UserResource {
156
+ public constructor(private data: Record<string, unknown>) {}
157
+
158
+ public toJSON() {
159
+ return {
160
+ id: this.data.id,
161
+ displayName: this.data.name,
162
+ contactEmail: this.data.email,
163
+ avatar: this.data.image ?? null,
164
+ };
165
+ }
166
+ }
167
+
168
+ @RegisterModel()
169
+ export class User extends Model<UserSchema> {
170
+ public static table = "users";
171
+ public static schema = userSchema;
172
+ public static resource = UserResource;
173
+ }
174
+ ```
175
+
176
+ Plain TypeScript class. No framework dependencies. `static resourceColumns` narrows which columns reach the resource; pair the two for strongly-typed public output.
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
+
191
+ ## Things NOT to do
192
+
193
+ - Don't `new User()` to create a record — `User.create({...})` validates, persists, fires events.
194
+ - Don't `.set("relation_name", instance)` for a relation slot. Use `setRelation("name", instance)`.
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.
197
+ - Don't `await user.save()` and forget the `await` — your changes live only on the instance and never reach the DB.
198
+ - Don't expect schema defaults to apply on `.merge()` — defaults fire on `.create()` only.
199
+
200
+ ## See also
201
+
202
+ - [`@warlock.js/cascade/query-data/SKILL.md`](@warlock.js/cascade/query-data/SKILL.md) — finding and filtering records
203
+ - [`@warlock.js/cascade/define-relations/SKILL.md`](@warlock.js/cascade/define-relations/SKILL.md) — relations and eager loading
204
+ - [`@warlock.js/cascade/track-changes/SKILL.md`](@warlock.js/cascade/track-changes/SKILL.md) — dirty tracking
205
+ - [`@warlock.js/cascade/subscribe-to-model-events/SKILL.md`](@warlock.js/cascade/subscribe-to-model-events/SKILL.md) — lifecycle hooks