@rudderjs/orm 1.8.1 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -298,6 +298,45 @@ await user!.loadMissing('profile', 'posts')
298
298
 
299
299
  ---
300
300
 
301
+ ## Vector search
302
+
303
+ `whereVectorSimilarTo(column, query, opts?)` runs a pgvector similarity query on the column. The terminal call (`get`/`first`/`all`) routes through raw SQL because pgvector ops (`<=>`, `<->`, `<#>`) can't be expressed in the fluent select APIs of either adapter, but everything else — `where(...)` chains, `limit`, `offset`, hydration, casts — composes normally.
304
+
305
+ ```ts
306
+ const matches = await Document
307
+ .where('tenantId', tenantId)
308
+ .whereVectorSimilarTo('embedding', queryEmbedding, {
309
+ metric: 'cosine', // 'cosine' (default) | 'euclidean' | 'inner_product'
310
+ minSimilarity: 0.75, // optional — 1 - distance threshold
311
+ limit: 10,
312
+ })
313
+ .selectVectorDistance('embedding', queryEmbedding, 'distance') // optional projected column
314
+ .get()
315
+
316
+ // matches: Document[] with .distance set when selectVectorDistance() is used
317
+ ```
318
+
319
+ **`query`** is either a `number[]` (pre-computed embedding) or a string — when a string, the adapter calls the registered embedder (see `@rudderjs/ai`'s `similaritySearch` lift) to produce the vector. Without a registered embedder, passing a string throws `MissingEmbedderError`.
320
+
321
+ **Requirements** — Postgres + the pgvector extension, and a `vector(N)` column on the table:
322
+
323
+ ```sql
324
+ CREATE EXTENSION IF NOT EXISTS vector;
325
+ ALTER TABLE documents ADD COLUMN embedding vector(1536);
326
+ ```
327
+
328
+ Helper: `pnpm rudder make:migration <name> --vector` scaffolds a migration with the `CREATE EXTENSION` + column add already filled in.
329
+
330
+ **Limitations (v1):**
331
+
332
+ - `whereGroup` / `orWhereGroup` / `whereHas` cannot wrap a `whereVectorSimilarTo` call — the raw-SQL path can't be nested inside a fluent subquery.
333
+ - `.with(...)` and `withCount` / `withSum` / etc. don't compose with vector queries yet.
334
+ - `orderBy` is redundant (vector queries always order by similarity) and throws if combined.
335
+ - `count()` alongside `whereVectorSimilarTo` is not supported.
336
+ - pgvector-only — SQLite/MySQL throw `VectorStorageUnsupportedError`.
337
+
338
+ ---
339
+
301
340
  ## Route model binding
302
341
 
303
342
  Models opt into route binding by exposing `static routeKey` (defaults to `'id'`) and `static findForRoute(value)`. The router's `router.bind(name, ModelClass)` API picks them up:
@@ -364,9 +403,23 @@ class Post extends Model {
364
403
  | `'encrypted'` | Decrypts string | Encrypts string |
365
404
  | `'encrypted:array'` | Decrypts + parses JSON | Encrypts JSON |
366
405
  | `'encrypted:object'` | Decrypts + parses JSON | Encrypts JSON |
406
+ | `vector({ dimensions })` | `number[]` from pgvector text | `number[]` → pgvector text literal |
367
407
 
368
408
  Encrypted casts require `@rudderjs/crypt` to be installed.
369
409
 
410
+ The `vector` cast is a factory — call it with the column's dimension so writes validate (`VectorDimensionMismatchError` on bad length, `NaN`, or `Infinity` before the SQL ever runs). Pair with `whereVectorSimilarTo()` for similarity search — see [Vector search](#vector-search) below.
411
+
412
+ ```ts
413
+ import { Model, vector } from '@rudderjs/orm'
414
+
415
+ class Document extends Model {
416
+ static override casts = {
417
+ embedding: vector({ dimensions: 1536 }),
418
+ }
419
+ declare embedding: number[]
420
+ }
421
+ ```
422
+
370
423
  ### Custom cast classes
371
424
 
372
425
  ```ts
@@ -1,267 +1,40 @@
1
1
  ---
2
2
  name: orm-models
3
3
  description: Creating Eloquent-style models, queries, relationships, casts, factories, and API resources in RudderJS
4
+ license: MIT
5
+ appliesTo:
6
+ - '@rudderjs/orm'
7
+ - '@rudderjs/orm-prisma'
8
+ trigger: creating or editing a `Model` class under `app/Models/`, writing queries / relationships, defining casts / accessors / factories, or building `JsonResource` API resources
9
+ skip: a route handler that only reads a model — controller-views is enough
10
+ metadata:
11
+ author: rudderjs
4
12
  ---
5
13
 
6
14
  # ORM Models
7
15
 
8
16
  ## When to use this skill
9
17
 
10
- Load this skill when you need to create or modify ORM models, write database queries, define casts/accessors/mutators, build model factories for testing, or create JSON API resources.
18
+ Load when you're creating or editing a `Model` class under `app/Models/`, writing queries, defining casts / accessors / factories, or building `JsonResource` API resources. For depth, open the rule file matching your task.
11
19
 
12
- ## Key concepts
20
+ ## Quick Reference
13
21
 
14
- - **Model base class**: All models extend `Model` from `@rudderjs/orm`. The ORM is adapter-based -- `ModelRegistry.set(adapter)` plugs in the actual DB driver (e.g. Prisma).
15
- - **Table naming**: Defaults to lowercase class name + `'s'` (e.g. `User` -> `users`). Override with `static table = 'my_table'`.
16
- - **Primary key**: Defaults to `'id'`. Override with `static primaryKey = 'uuid'`.
17
- - **Soft deletes**: Set `static softDeletes = true` to make `delete()` set `deletedAt` instead of removing the row.
18
- - **Decorators**: `@Hidden`, `@Visible`, `@Appends`, `@Cast` configure serialization on instance properties.
19
- - **Adapter pattern**: The ORM has no runtime DB dependency. The adapter (Prisma, Drizzle, etc.) is registered at boot time.
22
+ | Task | Open |
23
+ |---|---|
24
+ | Define a model `static table`, `fillable`, `hidden`, `casts`, soft deletes, decorators, custom casts | `rules/defining-models.md` |
25
+ | Query the database `find` / `where` / `paginate`, eager loading, scopes, soft-delete filters | `rules/querying.md` |
26
+ | CRUD + observers `create` / `update` / `delete`, observer lifecycle, atomic counters | `rules/crud-and-observers.md` |
27
+ | Test data `ModelFactory`, `sequence`, states, `.make()` vs `.create()` | `rules/factories.md` |
28
+ | API output — `JsonResource`, `ResourceCollection`, `when` / `whenLoaded` / `mergeWhen` | `rules/resources.md` |
20
29
 
21
- ## Step-by-step
30
+ ## Key concepts (load once)
22
31
 
23
- ### 1. Define a model
24
-
25
- ```ts
26
- import { Model, Hidden, Cast, Attribute } from '@rudderjs/orm'
27
-
28
- export class Post extends Model {
29
- static table = 'posts'
30
- static fillable = ['title', 'body', 'authorId']
31
- static hidden = ['internalNotes']
32
-
33
- static casts = {
34
- isPublished: 'boolean' as const,
35
- publishedAt: 'datetime' as const,
36
- metadata: 'json' as const,
37
- }
38
-
39
- static attributes = {
40
- excerpt: Attribute.make({
41
- get: (_, attrs) => String(attrs['body'] ?? '').slice(0, 200),
42
- }),
43
- }
44
-
45
- static appends = ['excerpt']
46
- }
47
- ```
48
-
49
- ### 2. Use decorator syntax (alternative)
50
-
51
- ```ts
52
- import { Model, Hidden, Cast, Appends, Attribute } from '@rudderjs/orm'
53
-
54
- export class User extends Model {
55
- static fillable = ['name', 'email', 'password']
56
-
57
- @Hidden password = ''
58
- @Cast('boolean') isAdmin = false
59
- @Cast('date') createdAt = new Date()
60
- @Appends fullName = ''
61
-
62
- static attributes = {
63
- fullName: Attribute.make({
64
- get: (_, attrs) => `${attrs['firstName']} ${attrs['lastName']}`,
65
- }),
66
- password: Attribute.make({
67
- set: async (v) => {
68
- const { hash } = await import('bcrypt')
69
- return hash(String(v), 10)
70
- },
71
- }),
72
- }
73
- }
74
- ```
75
-
76
- ### 3. Query the database
77
-
78
- ```ts
79
- // Basic queries
80
- const user = await User.find(1)
81
- const users = await User.all()
82
- const first = await User.first()
83
- const total = await User.count()
84
-
85
- // Filtered queries
86
- const admins = await User.where('isAdmin', true).all()
87
- const page = await User.paginate(1, 15) // { data, total, page, perPage, lastPage }
88
-
89
- // Chained query builder
90
- const results = await User.query()
91
- .where('role', 'admin')
92
- .where('active', true)
93
- .orderBy('createdAt', 'desc')
94
- .limit(10)
95
- .all()
96
-
97
- // Eager loading
98
- const posts = await Post.with('author', 'comments').all()
99
- ```
100
-
101
- ### 4. CRUD operations
102
-
103
- ```ts
104
- // Create -- triggers creating/created observers
105
- const post = await Post.create({ title: 'Hello', body: 'World', authorId: 1 })
106
-
107
- // Update -- triggers updating/updated observers
108
- const updated = await Post.update(post.id, { title: 'Updated' })
109
-
110
- // Delete -- triggers deleting/deleted observers
111
- await Post.delete(post.id)
112
-
113
- // Soft delete (when softDeletes = true)
114
- await Post.delete(post.id) // sets deletedAt
115
- await Post.restore(post.id) // clears deletedAt
116
- await Post.forceDelete(post.id) // permanent removal
117
- ```
118
-
119
- ### 5. Scopes
120
-
121
- ```ts
122
- export class Post extends Model {
123
- static globalScopes = {
124
- published: (q) => q.where('isPublished', true),
125
- }
126
-
127
- static scopes = {
128
- byAuthor: (q, authorId: number) => q.where('authorId', authorId),
129
- recent: (q) => q.orderBy('createdAt', 'desc').limit(10),
130
- }
131
- }
132
-
133
- // Usage
134
- const posts = await Post.query().scope('byAuthor', 1).scope('recent').all()
135
- const allPosts = await Post.query().withoutGlobalScope('published').all()
136
- ```
137
-
138
- ### 6. Observers
139
-
140
- ```ts
141
- import type { ModelObserver } from '@rudderjs/orm'
142
-
143
- class PostObserver implements ModelObserver {
144
- creating(data: Record<string, unknown>) {
145
- data['slug'] = slugify(String(data['title']))
146
- return data
147
- }
148
-
149
- deleted(id: string | number) {
150
- console.log(`Post ${id} was deleted`)
151
- }
152
- }
153
-
154
- Post.observe(PostObserver)
155
-
156
- // Or inline listeners
157
- Post.on('creating', (data) => { data['slug'] = slugify(data['title']); return data })
158
- ```
159
-
160
- ### 7. Custom casts
161
-
162
- ```ts
163
- import type { CastUsing } from '@rudderjs/orm'
164
-
165
- class MoneyCast implements CastUsing {
166
- get(key: string, value: unknown): number {
167
- return Number(value) / 100 // stored as cents
168
- }
169
- set(key: string, value: unknown): number {
170
- return Math.round(Number(value) * 100)
171
- }
172
- }
173
-
174
- class Product extends Model {
175
- static casts = { price: MoneyCast }
176
- // Or decorator: @Cast(MoneyCast) price = 0
177
- }
178
- ```
179
-
180
- ### 8. Model factories
181
-
182
- ```ts
183
- import { ModelFactory, sequence } from '@rudderjs/orm'
184
-
185
- class UserFactory extends ModelFactory<{ name: string; email: string; role: string }> {
186
- protected modelClass = User
187
-
188
- definition() {
189
- return {
190
- name: 'Alice',
191
- email: sequence(i => `user${i}@example.com`),
192
- role: 'user',
193
- }
194
- }
195
-
196
- protected states() {
197
- return {
198
- admin: () => ({ role: 'admin' }),
199
- }
200
- }
201
- }
202
-
203
- // Usage
204
- const user = await UserFactory.new().create()
205
- const admin = await UserFactory.new().state('admin').create()
206
- const users = await UserFactory.new().create(5) // 5 persisted
207
- const dtos = await UserFactory.new().make(3) // 3 in-memory only
208
- const custom = await UserFactory.new().with(() => ({ name: 'Bob' })).create()
209
- ```
210
-
211
- ### 9. API resources
212
-
213
- ```ts
214
- import { JsonResource, ResourceCollection } from '@rudderjs/orm'
215
-
216
- class UserResource extends JsonResource<User> {
217
- toArray() {
218
- return {
219
- id: this.resource.id,
220
- name: this.resource.name,
221
- email: this.resource.email,
222
- admin: this.when(this.resource.role === 'admin', true),
223
- posts: this.whenLoaded('posts', PostResource.collection(this.resource.posts)),
224
- ...this.mergeWhen(this.resource.isAdmin, {
225
- permissions: this.resource.permissions,
226
- }),
227
- }
228
- }
229
- }
230
-
231
- // Single resource
232
- res.json(new UserResource(user).toArray())
233
-
234
- // Collection with pagination
235
- const collection = UserResource.collection(users, { total: 100, page: 1, perPage: 15 })
236
- res.json(await collection.toResponse())
237
- // -> { data: [...], meta: { total: 100, page: 1, perPage: 15 } }
238
- ```
239
-
240
- ### 10. Instance serialization controls
241
-
242
- ```ts
243
- const user = await User.find(1)
244
- user.makeVisible('password') // show normally-hidden field
245
- user.makeHidden('email') // hide for this instance
246
- user.setVisible(['id', 'name']) // allowlist override
247
-
248
- // On collections
249
- const collection = ModelCollection.wrap(await User.all())
250
- collection.makeHidden(['email'])
251
- collection.modelKeys() // [1, 2, 3]
252
- collection.find(2) // user with id 2
253
- collection.except([1]) // all except id 1
254
- ```
32
+ - **Adapter pattern** — `ModelRegistry.set(adapter)` plugs in the DB driver. `@rudderjs/orm` has no runtime DB dependency.
33
+ - **Hydration** — every read (`find`/`first`/`all`/`where(...).get()`/`paginate`) returns Model instances, not plain records. Use `Model.hydrate(record)` to wrap external data (cached JSON, fixtures).
34
+ - **Mass assignment** — `static fillable` (allowlist) / `static guarded` (denylist; `['*']` locks all) drop keys outside policy on `create` / `update` / `fill`. `instance.forceFill` bypasses.
35
+ - **Observers** — `Model.create/update/delete` fire lifecycle events. `query().create()` bypasses them. `increment` / `decrement` deliberately do **not** fire — pure data-plane operations.
36
+ - **Built-in casts**: `'string'`, `'integer'`, `'float'`, `'boolean'`, `'date'`, `'datetime'`, `'json'`, `'array'`, `'collection'`, `'encrypted'`, `'encrypted:array'`, `'encrypted:object'`. Encrypted casts need `@rudderjs/crypt`.
255
37
 
256
38
  ## Examples
257
39
 
258
- See `playground/app/Models/User.ts` for a working model and `playground/routes/console.ts` for seeding with factories.
259
-
260
- ## Common pitfalls
261
-
262
- - **No adapter registered**: `ModelRegistry.getAdapter()` throws if no database provider is in the provider list. Ensure `DatabaseServiceProvider` boots before any model usage.
263
- - **Observers vs query builder**: `Model.create()` / `Model.update()` / `Model.delete()` fire observer events. Using `Model.query().create()` directly bypasses observers.
264
- - **Built-in casts**: Available types are `'string'`, `'integer'`, `'float'`, `'boolean'`, `'date'`, `'datetime'`, `'json'`, `'array'`, `'collection'`, `'encrypted'`, `'encrypted:array'`, `'encrypted:object'`. Encrypted casts require `@rudderjs/crypt`.
265
- - **Visible vs hidden**: When `visible` is set (allowlist), `hidden` is ignored. Only one should be used per model.
266
- - **sequence() in factories**: The `sequence()` helper returns a callable. Inside `definition()`, return it directly -- the factory resolves callables automatically.
267
- - **Prisma schema**: Models map to Prisma models. Run `pnpm exec prisma generate` after schema changes and `pnpm exec prisma db push` to sync the DB.
40
+ See `playground/app/Models/User.ts` for a working model and `playground/routes/console.ts` for factory-based seeding.
@@ -0,0 +1,130 @@
1
+ # CRUD and Observers
2
+
3
+ ## Create / update / delete
4
+
5
+ ```ts
6
+ // Create — triggers creating / created observer events
7
+ const post = await Post.create({ title: 'Hello', body: 'World', authorId: 1 })
8
+
9
+ // Update — triggers updating / updated
10
+ const updated = await Post.update(post.id, { title: 'Hello v2' })
11
+
12
+ // Delete — triggers deleting / deleted
13
+ await Post.delete(post.id)
14
+ ```
15
+
16
+ Mass assignment (`fillable` / `guarded`) filters keys on all three. To bypass: `instance.forceFill(data)` or set properties directly and call `instance.save()`.
17
+
18
+ ## firstOrCreate / updateOrCreate
19
+
20
+ ```ts
21
+ const tag = await Tag.firstOrCreate({ name: 'rust' }, { description: 'systems language' })
22
+ // SELECT, then INSERT if not found
23
+
24
+ const user = await User.updateOrCreate({ email }, { name, lastSeenAt: new Date() })
25
+ // SELECT, then INSERT or UPDATE
26
+ ```
27
+
28
+ ⚠️ Lookup keys (the first arg) go through `create()`, so they need to be **fillable** too — otherwise the lookup column isn't set on the new row.
29
+
30
+ ## Atomic counters
31
+
32
+ For columns that change in concurrent writes (view counts, balances):
33
+
34
+ ```ts
35
+ await Post.increment(postId, 'viewCount') // viewCount = viewCount + 1
36
+ await Post.decrement(postId, 'stock', 5) // stock = stock - 5
37
+
38
+ // Instance variant merges the resolved value back
39
+ await post.increment('viewCount') // post.viewCount reflects the update
40
+ ```
41
+
42
+ **Observers do NOT fire** on increment/decrement — they're pure data-plane. If you need observer hooks, read the row, set the resolved value, and call `Model.update()` instead.
43
+
44
+ ## Observers
45
+
46
+ ```ts
47
+ import type { ModelObserver } from '@rudderjs/orm'
48
+
49
+ class PostObserver implements ModelObserver {
50
+ creating(data: Record<string, unknown>) {
51
+ data['slug'] = slugify(String(data['title']))
52
+ return data
53
+ }
54
+
55
+ deleted(id: string | number) {
56
+ console.log(`Post ${id} was deleted`)
57
+ }
58
+ }
59
+
60
+ Post.observe(PostObserver)
61
+ ```
62
+
63
+ Inline listeners are also supported:
64
+
65
+ ```ts
66
+ Post.on('creating', (data) => {
67
+ data['slug'] = slugify(String(data['title']))
68
+ return data
69
+ })
70
+ ```
71
+
72
+ Events fired by lifecycle: `retrieved`, `creating` → `created`, `updating` → `updated`, `saving` → `saved` (on both create + update), `deleting` → `deleted`, `restoring` → `restored`.
73
+
74
+ ## Soft delete + restore
75
+
76
+ ```ts
77
+ class Post extends Model { static softDeletes = true }
78
+
79
+ await Post.delete(id) // sets deletedAt
80
+ await Post.restore(id) // clears deletedAt — fires restoring / restored
81
+ await Post.forceDelete(id) // hard delete — fires deleting / deleted with no soft-delete
82
+ ```
83
+
84
+ ## Pitfalls
85
+
86
+ ❌ **Don't** rely on observers firing for `query().create()`:
87
+
88
+ ```ts
89
+ await Post.query().create({ title }) // bypasses observers
90
+ ```
91
+
92
+ ✅ **Do** use the static method for observer-aware writes:
93
+
94
+ ```ts
95
+ await Post.create({ title }) // fires creating / created
96
+ ```
97
+
98
+ ❌ **Don't** expect observers on counter updates:
99
+
100
+ ```ts
101
+ class Post extends Model {
102
+ static observe = class {
103
+ updated(post: Post) { /* won't fire for increment() */ }
104
+ }
105
+ }
106
+ ```
107
+
108
+ ✅ **Do** read + write through `update()` if you need hooks:
109
+
110
+ ```ts
111
+ const post = await Post.find(id)
112
+ await Post.update(id, { viewCount: post.viewCount + 1 }) // fires updating / updated
113
+ ```
114
+
115
+ ❌ **Don't** rely on lookup keys being set if they're not fillable:
116
+
117
+ ```ts
118
+ class Tag extends Model {
119
+ static fillable = ['description'] // no 'name'
120
+ }
121
+ await Tag.firstOrCreate({ name: 'rust' }, { description: 'lang' }) // name dropped → new row missing name
122
+ ```
123
+
124
+ ✅ **Do** include lookup keys in `fillable`:
125
+
126
+ ```ts
127
+ class Tag extends Model {
128
+ static fillable = ['name', 'description']
129
+ }
130
+ ```
@@ -0,0 +1,137 @@
1
+ # Defining Models
2
+
3
+ ## Basic shape
4
+
5
+ ```ts
6
+ import { Model } from '@rudderjs/orm'
7
+
8
+ export class Post extends Model {
9
+ static table = 'posts'
10
+ static fillable = ['title', 'body', 'authorId']
11
+ static hidden = ['internalNotes']
12
+
13
+ static casts = {
14
+ isPublished: 'boolean' as const,
15
+ publishedAt: 'datetime' as const,
16
+ metadata: 'json' as const,
17
+ }
18
+ }
19
+ ```
20
+
21
+ Table defaults to lowercase class name + `'s'`. Override with `static table = 'my_table'`.
22
+ Primary key defaults to `'id'`. Override with `static primaryKey = 'uuid'`.
23
+
24
+ ## Decorator syntax (alternative)
25
+
26
+ ```ts
27
+ import { Model, Hidden, Cast, Appends } from '@rudderjs/orm'
28
+
29
+ export class User extends Model {
30
+ static fillable = ['name', 'email', 'password']
31
+
32
+ @Hidden password = ''
33
+ @Cast('boolean') isAdmin = false
34
+ @Cast('date') createdAt = new Date()
35
+ @Appends fullName = ''
36
+ }
37
+ ```
38
+
39
+ Pick **one style per model** — mixing decorators and `static` config for the same fields is confusing.
40
+
41
+ ## Accessors and mutators
42
+
43
+ ```ts
44
+ import { Model, Attribute } from '@rudderjs/orm'
45
+
46
+ export class User extends Model {
47
+ static attributes = {
48
+ fullName: Attribute.make({
49
+ get: (_v, attrs) => `${attrs['firstName']} ${attrs['lastName']}`,
50
+ }),
51
+ password: Attribute.make({
52
+ set: async (v) => (await import('bcrypt')).hash(String(v), 10),
53
+ }),
54
+ }
55
+
56
+ static appends = ['fullName'] // include in toJSON output
57
+ }
58
+ ```
59
+
60
+ Mutators (`set`) run on `Model.create` / `Model.update` / `instance.fill`. Accessors (`get`) run on serialization. Appended attributes need a matching `get` definition, otherwise the field is missing from output.
61
+
62
+ ## Custom casts
63
+
64
+ For domain types (money, vectors, custom serialization):
65
+
66
+ ```ts
67
+ import type { CastUsing } from '@rudderjs/orm'
68
+
69
+ class MoneyCast implements CastUsing {
70
+ get(_key: string, value: unknown): number { return Number(value) / 100 }
71
+ set(_key: string, value: unknown): number { return Math.round(Number(value) * 100) }
72
+ }
73
+
74
+ class Product extends Model {
75
+ static casts = { price: MoneyCast }
76
+ // or decorator: @Cast(MoneyCast) price = 0
77
+ }
78
+ ```
79
+
80
+ ## Soft deletes
81
+
82
+ ```ts
83
+ export class Post extends Model {
84
+ static softDeletes = true
85
+ }
86
+
87
+ await Post.delete(id) // sets deletedAt
88
+ await Post.restore(id) // clears deletedAt
89
+ await Post.forceDelete(id) // hard delete
90
+ await Post.query().withTrashed().all() // include soft-deleted
91
+ await Post.query().onlyTrashed().all() // only soft-deleted
92
+ ```
93
+
94
+ ## Pitfalls
95
+
96
+ ❌ **Don't** set `static visible` AND `static hidden` on the same model:
97
+
98
+ ```ts
99
+ static visible = ['id', 'name']
100
+ static hidden = ['password'] // silently ignored when `visible` is set
101
+ ```
102
+
103
+ ✅ **Do** pick one. `visible` is a strict allowlist; `hidden` is a denylist.
104
+
105
+ ❌ **Don't** type a relation field that shadows the runtime-installed accessor:
106
+
107
+ ```ts
108
+ class Post extends Model {
109
+ tags!: () => string[] // class field shadows the prototype method
110
+ }
111
+ ```
112
+
113
+ ✅ **Do** type the explicit override:
114
+
115
+ ```ts
116
+ class Post extends Model {
117
+ tags() { return Model.morphToMany(this, 'tags') }
118
+ }
119
+ ```
120
+
121
+ ❌ **Don't** assume the SQL table name is the Prisma delegate:
122
+
123
+ ```ts
124
+ class OAuthClient extends Model {
125
+ static table = 'oauth_clients' // wrong — that's the @@map'd SQL name
126
+ }
127
+ ```
128
+
129
+ ✅ **Do** use the Prisma client delegate (camelCase of model name):
130
+
131
+ ```ts
132
+ class OAuthClient extends Model {
133
+ static table = 'oAuthClient' // the Prisma delegate
134
+ }
135
+ ```
136
+
137
+ Error: `[RudderJS ORM] Prisma has no delegate for table "oauth_clients"` means you used the SQL name by mistake.