@rudderjs/orm 0.0.6

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Suleiman Shahbari
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,604 @@
1
+ # @rudderjs/orm
2
+
3
+ ORM contract, `Model` base class, and `ModelRegistry` for RudderJS applications.
4
+
5
+ ```bash
6
+ pnpm add @rudderjs/orm
7
+ ```
8
+
9
+ This package provides the shared abstractions. For a working database connection use an adapter:
10
+
11
+ - `@rudderjs/orm-prisma` — Prisma adapter (SQLite, PostgreSQL, MySQL)
12
+ - `@rudderjs/orm-drizzle` — Drizzle adapter (SQLite, PostgreSQL, LibSQL)
13
+
14
+ ---
15
+
16
+ ## Setup
17
+
18
+ Register a database provider in `bootstrap/providers.ts`:
19
+
20
+ ```ts
21
+ import { database } from '@rudderjs/orm-prisma'
22
+ import configs from '../config/index.js'
23
+
24
+ export default [
25
+ database(configs.database),
26
+ // ...other providers
27
+ ]
28
+ ```
29
+
30
+ The provider calls `ModelRegistry.set(adapter)` during boot — no manual wiring needed.
31
+
32
+ ---
33
+
34
+ ## Defining a Model
35
+
36
+ ```ts
37
+ import { Model } from '@rudderjs/orm'
38
+
39
+ export class User extends Model {
40
+ static override table = 'users' // optional — defaults to lowercase class name + 's'
41
+ static override fillable = ['name', 'email', 'role']
42
+ static override hidden = ['password']
43
+
44
+ declare id: number
45
+ declare name: string
46
+ declare email: string
47
+ declare password: string
48
+ }
49
+ ```
50
+
51
+ ---
52
+
53
+ ## Querying
54
+
55
+ All static query methods delegate to the registered adapter's `QueryBuilder`.
56
+
57
+ ```ts
58
+ // Find by primary key — returns null if not found
59
+ const user = await User.find(1)
60
+
61
+ // Fetch all rows
62
+ const users = await User.all()
63
+
64
+ // Conditional query — returns a chainable QueryBuilder
65
+ const admins = await User.where('role', 'admin').get()
66
+
67
+ // Eager-load relations
68
+ const posts = await Post.with('author', 'tags').get()
69
+
70
+ // Raw query builder
71
+ const recent = await User.query()
72
+ .orderBy('createdAt', 'desc')
73
+ .limit(10)
74
+ .get()
75
+ ```
76
+
77
+ ### QueryBuilder methods
78
+
79
+ | Method | Returns | Description |
80
+ |---|---|---|
81
+ | `where(col, val)` | `QueryBuilder` | Add a WHERE clause |
82
+ | `orWhere(col, val)` | `QueryBuilder` | Add an OR WHERE clause |
83
+ | `orderBy(col, dir)` | `QueryBuilder` | Add ORDER BY |
84
+ | `limit(n)` | `QueryBuilder` | Limit result count |
85
+ | `offset(n)` | `QueryBuilder` | Skip n rows |
86
+ | `with(...rels)` | `QueryBuilder` | Eager-load relations |
87
+ | `scope(name, ...args)` | `QueryBuilder` | Apply a local scope defined in `static scopes` |
88
+ | `withoutGlobalScope(name)` | `QueryBuilder` | Rebuild the query excluding a named global scope |
89
+ | `first()` | `Promise<T \| null>` | First matching row |
90
+ | `find(id)` | `Promise<T \| null>` | Find by primary key |
91
+ | `get()` | `Promise<T[]>` | All matching rows |
92
+ | `all()` | `Promise<T[]>` | All rows (no conditions) |
93
+ | `count()` | `Promise<number>` | Row count |
94
+ | `create(data)` | `Promise<T>` | Insert a new row |
95
+ | `update(id, data)` | `Promise<T>` | Update a row by primary key |
96
+ | `delete(id)` | `Promise<void>` | Delete a row by primary key |
97
+ | `paginate(page, perPage)` | `Promise<PaginatedResult<T>>` | Paginated results |
98
+
99
+ ### Creating records
100
+
101
+ ```ts
102
+ const user = await User.create({ name: 'Alice', email: 'alice@example.com' })
103
+ ```
104
+
105
+ ---
106
+
107
+ ## Attribute Casts
108
+
109
+ Casts automatically transform attribute values when reading from and writing to the database.
110
+
111
+ ```ts
112
+ import { Model } from '@rudderjs/orm'
113
+
114
+ class Post extends Model {
115
+ static override casts = {
116
+ isPublished: 'boolean',
117
+ publishedAt: 'date',
118
+ metadata: 'json',
119
+ viewCount: 'integer',
120
+ rating: 'float',
121
+ tags: 'array',
122
+ } as const
123
+
124
+ declare isPublished: boolean
125
+ declare publishedAt: Date
126
+ declare metadata: Record<string, unknown>
127
+ declare viewCount: number
128
+ declare rating: number
129
+ declare tags: string[]
130
+ }
131
+ ```
132
+
133
+ ### Built-in cast types
134
+
135
+ | Cast | Get (read) | Set (write) |
136
+ |---|---|---|
137
+ | `'string'` | `String(v)` | `String(v)` |
138
+ | `'integer'` | `parseInt(v)` | `parseInt(v)` |
139
+ | `'float'` | `parseFloat(v)` | `parseFloat(v)` |
140
+ | `'boolean'` | `true/false` from truthy values | `1` / `0` |
141
+ | `'date'` | `new Date(v)` | `toISOString().slice(0,10)` |
142
+ | `'datetime'` | `new Date(v)` | `toISOString()` |
143
+ | `'json'` | `JSON.parse(v)` | `JSON.stringify(v)` |
144
+ | `'array'` | `JSON.parse(v)` | `JSON.stringify(v)` |
145
+ | `'collection'` | `JSON.parse(v)` (as array) | `JSON.stringify(v)` |
146
+ | `'encrypted'` | Decrypts string | Encrypts string |
147
+ | `'encrypted:array'` | Decrypts + parses JSON | Encrypts JSON |
148
+ | `'encrypted:object'` | Decrypts + parses JSON | Encrypts JSON |
149
+
150
+ Encrypted casts require `@rudderjs/crypt` to be installed.
151
+
152
+ ### Custom cast classes
153
+
154
+ ```ts
155
+ import type { CastUsing } from '@rudderjs/orm'
156
+
157
+ class MoneyCast implements CastUsing {
158
+ get(key: string, value: unknown) {
159
+ return Number(value) / 100 // cents → dollars
160
+ }
161
+ set(key: string, value: unknown) {
162
+ return Math.round(Number(value) * 100) // dollars → cents
163
+ }
164
+ }
165
+
166
+ class Product extends Model {
167
+ static override casts = { price: MoneyCast }
168
+ }
169
+ ```
170
+
171
+ ### `@Cast` decorator
172
+
173
+ ```ts
174
+ import { Model, Cast } from '@rudderjs/orm'
175
+
176
+ class User extends Model {
177
+ @Cast('boolean') isAdmin = false
178
+ @Cast('date') createdAt = new Date()
179
+ @Cast(MoneyCast) balance = 0
180
+ }
181
+ ```
182
+
183
+ ---
184
+
185
+ ## Accessors & Mutators
186
+
187
+ Define computed getters and write transformations using `Attribute.make()`.
188
+
189
+ ```ts
190
+ import { Model, Attribute } from '@rudderjs/orm'
191
+
192
+ class User extends Model {
193
+ static override attributes = {
194
+ // Accessor — transform on read
195
+ firstName: Attribute.make({
196
+ get: (value) => String(value).charAt(0).toUpperCase() + String(value).slice(1),
197
+ }),
198
+
199
+ // Computed from multiple columns
200
+ fullName: Attribute.make({
201
+ get: (_, attrs) => `${attrs['firstName']} ${attrs['lastName']}`,
202
+ }),
203
+
204
+ // Mutator — transform on write (create/update)
205
+ password: Attribute.make({
206
+ set: (value) => hashSync(String(value)),
207
+ }),
208
+
209
+ // Both accessor and mutator
210
+ email: Attribute.make({
211
+ get: (v) => String(v).toLowerCase(),
212
+ set: (v) => String(v).toLowerCase().trim(),
213
+ }),
214
+ }
215
+ }
216
+ ```
217
+
218
+ - **Accessors** run in `toJSON()` and transform the raw stored value.
219
+ - **Mutators** run in `Model.create()` and `Model.update()` before data hits the database.
220
+ - Attribute accessors take priority over casts for the same key.
221
+
222
+ ---
223
+
224
+ ## Serialization Controls
225
+
226
+ ### `static hidden` / `static visible`
227
+
228
+ ```ts
229
+ class User extends Model {
230
+ static override hidden = ['password', 'rememberToken'] // denylist
231
+ }
232
+
233
+ class PublicUser extends Model {
234
+ static override visible = ['id', 'name', 'avatar'] // allowlist (takes precedence)
235
+ }
236
+ ```
237
+
238
+ ### `static appends`
239
+
240
+ Always include computed accessor values in JSON output:
241
+
242
+ ```ts
243
+ class User extends Model {
244
+ static override appends = ['fullName']
245
+
246
+ static override attributes = {
247
+ fullName: Attribute.make({
248
+ get: (_, attrs) => `${attrs['firstName']} ${attrs['lastName']}`,
249
+ }),
250
+ }
251
+ }
252
+
253
+ JSON.stringify(user) // includes "fullName" even though it's not a stored column
254
+ ```
255
+
256
+ ### Decorators
257
+
258
+ ```ts
259
+ import { Model, Hidden, Visible, Appends } from '@rudderjs/orm'
260
+
261
+ class User extends Model {
262
+ @Hidden password = '' // added to static hidden
263
+ @Visible id = 0 // added to static visible
264
+ @Visible name = ''
265
+ @Appends fullName = '' // added to static appends
266
+ }
267
+ ```
268
+
269
+ ### Instance-level overrides
270
+
271
+ ```ts
272
+ const user = await User.find(1)
273
+
274
+ // Temporarily show hidden fields
275
+ user.makeVisible(['password'])
276
+
277
+ // Temporarily hide fields
278
+ user.makeHidden(['email'])
279
+
280
+ // Replace the lists entirely
281
+ user.setVisible(['id', 'name'])
282
+ user.setHidden(['password', 'token'])
283
+
284
+ // Merge into existing lists
285
+ user.mergeVisible(['avatar'])
286
+ user.mergeHidden(['ssn'])
287
+
288
+ // All return `this` for chaining
289
+ user.makeVisible(['email']).makeHidden(['phone']).toJSON()
290
+ ```
291
+
292
+ ---
293
+
294
+ ## API Resources
295
+
296
+ Transform model data for API responses with conditional fields and nested resources.
297
+
298
+ ### `JsonResource`
299
+
300
+ ```ts
301
+ import { JsonResource } from '@rudderjs/orm'
302
+
303
+ class UserResource extends JsonResource<User> {
304
+ toArray() {
305
+ return {
306
+ id: this.resource.id,
307
+ name: this.resource.name,
308
+ email: this.resource.email,
309
+
310
+ // Only include when condition is true
311
+ admin: this.when(this.resource.role === 'admin', true),
312
+
313
+ // Only include when value is not null
314
+ bio: this.whenNotNull(this.resource.bio, (b) => b.trim()),
315
+
316
+ // Only include when relation is loaded
317
+ posts: this.whenLoaded('posts'),
318
+
319
+ // Merge multiple fields conditionally
320
+ ...this.mergeWhen(this.resource.isAdmin, {
321
+ permissions: this.resource.permissions,
322
+ lastLogin: this.resource.lastLogin,
323
+ }),
324
+ }
325
+ }
326
+ }
327
+
328
+ // Single resource
329
+ const json = new UserResource(user).toArray()
330
+
331
+ // Collection
332
+ const collection = UserResource.collection(users)
333
+ const response = await collection.toResponse()
334
+ // → { data: [...] }
335
+ ```
336
+
337
+ ### `ResourceCollection`
338
+
339
+ ```ts
340
+ import { ResourceCollection } from '@rudderjs/orm'
341
+
342
+ // With pagination metadata
343
+ const collection = UserResource.collection(users, {
344
+ total: 100, page: 1, perPage: 15,
345
+ })
346
+ const response = await collection.toResponse()
347
+ // → { data: [...], meta: { total: 100, page: 1, perPage: 15 } }
348
+ ```
349
+
350
+ ---
351
+
352
+ ## ModelCollection
353
+
354
+ Typed array wrapper with ORM-specific operations:
355
+
356
+ ```ts
357
+ import { ModelCollection } from '@rudderjs/orm'
358
+
359
+ const users = ModelCollection.wrap(await User.all())
360
+
361
+ users.modelKeys() // [1, 2, 3]
362
+ users.find(2) // item with id 2
363
+ users.contains(2) // true
364
+ users.contains(u => u.name === 'Alice') // predicate
365
+ users.except([1, 3]) // items not in list
366
+ users.only([1, 2]) // items in list
367
+ users.diff(otherUsers) // items not in other
368
+ users.unique('email') // deduplicated by key
369
+ users.isEmpty() // false
370
+ users.isNotEmpty() // true
371
+ users.count() // 3
372
+
373
+ // Serialization controls on each item
374
+ users.makeVisible(['password'])
375
+ users.makeHidden(['email'])
376
+
377
+ // Async ORM operations
378
+ const fresh = await users.fresh(User) // reload from DB
379
+ const loaded = await users.load(User, 'posts') // eager-load
380
+ const loaded2 = await users.loadMissing(User, 'posts') // load if missing
381
+ const query = users.toQuery(User) // query builder scoped to IDs
382
+ ```
383
+
384
+ ---
385
+
386
+ ## Model Factories
387
+
388
+ Create model instances for testing with named states and sequences.
389
+
390
+ ```ts
391
+ import { ModelFactory, sequence } from '@rudderjs/orm'
392
+
393
+ class UserFactory extends ModelFactory<{ name: string; email: string; role: string }> {
394
+ protected modelClass = User
395
+
396
+ definition() {
397
+ return {
398
+ name: 'Alice',
399
+ email: sequence(i => `user${i}@example.com`)(),
400
+ role: 'user',
401
+ }
402
+ }
403
+
404
+ protected states() {
405
+ return {
406
+ admin: () => ({ role: 'admin' }),
407
+ banned: () => ({ role: 'banned' }),
408
+ }
409
+ }
410
+ }
411
+
412
+ // Single record
413
+ const user = await UserFactory.new().create()
414
+
415
+ // With named state
416
+ const admin = await UserFactory.new().state('admin').create()
417
+
418
+ // Multiple records
419
+ const users = await UserFactory.new().create(5)
420
+
421
+ // Without saving to DB
422
+ const dto = await UserFactory.new().make()
423
+ const dtos = await UserFactory.new().make(3)
424
+
425
+ // With overrides
426
+ const custom = await UserFactory.new().create({ name: 'Bob' })
427
+
428
+ // Inline state
429
+ const mod = await UserFactory.new().with(() => ({ role: 'moderator' })).create()
430
+ ```
431
+
432
+ ### `sequence()`
433
+
434
+ Generates cycling or index-based values:
435
+
436
+ ```ts
437
+ // Array cycling
438
+ sequence(['Alice', 'Bob', 'Carol']) // returns a function: Alice → Bob → Carol → Alice → ...
439
+
440
+ // Index-based
441
+ sequence(i => `user${i}@example.com`) // user0@... → user1@... → user2@...
442
+ ```
443
+
444
+ ---
445
+
446
+ ## Scopes
447
+
448
+ ### Global Scopes
449
+
450
+ Applied automatically to every query on the model:
451
+
452
+ ```ts
453
+ export class Article extends Model {
454
+ static globalScopes = {
455
+ ordered: (q) => q.orderBy('createdAt', 'DESC'),
456
+ active: (q) => q.where('active', true),
457
+ }
458
+ }
459
+
460
+ await Article.query().get() // ordered + active
461
+ await Article.query().withoutGlobalScope('active').get() // ordered only
462
+ ```
463
+
464
+ ### Local Scopes
465
+
466
+ Reusable query fragments, opt-in via `.scope('name')`:
467
+
468
+ ```ts
469
+ export class Article extends Model {
470
+ static scopes = {
471
+ published: (q) => q.where('draftStatus', 'published'),
472
+ recent: (q) => q.where('createdAt', '>', new Date(Date.now() - 30 * 86400000).toISOString()),
473
+ byAuthor: (q, authorId: string) => q.where('authorId', authorId),
474
+ }
475
+ }
476
+
477
+ await Article.query().scope('published').scope('recent').get()
478
+ await Article.query().scope('byAuthor', userId).get()
479
+ ```
480
+
481
+ ---
482
+
483
+ ## Soft Deletes
484
+
485
+ ```ts
486
+ class Post extends Model {
487
+ static override softDeletes = true
488
+ }
489
+
490
+ await Post.delete(1) // sets deletedAt
491
+ await Post.restore(1) // clears deletedAt
492
+ await Post.forceDelete(1) // permanent delete
493
+
494
+ // Query helpers
495
+ Post.query().withTrashed().get() // include soft-deleted
496
+ Post.query().onlyTrashed().get() // only soft-deleted
497
+ ```
498
+
499
+ ---
500
+
501
+ ## Observers
502
+
503
+ Register lifecycle hooks on a model to transform data, log events, or cancel operations.
504
+
505
+ ### Observer Class
506
+
507
+ ```ts
508
+ class ArticleObserver {
509
+ creating(data) {
510
+ data.slug = slugify(data.title)
511
+ return data // return transformed data
512
+ }
513
+ created(record) { console.log('Article created:', record.id) }
514
+ updating(id, data) { return { ...data, updatedAt: new Date() } }
515
+ deleting(id) { /* return false to cancel */ }
516
+ }
517
+
518
+ Article.observe(ArticleObserver)
519
+ ```
520
+
521
+ ### Inline Listeners
522
+
523
+ ```ts
524
+ Article.on('creating', (data) => { data.slug = slugify(data.title); return data })
525
+ Article.on('deleting', (id) => { if (id === protectedId) return false })
526
+ ```
527
+
528
+ ### Events
529
+
530
+ | Event | Arguments | Can cancel? | Can transform? |
531
+ |---|---|---|---|
532
+ | `creating` | `data` | Yes | Yes |
533
+ | `created` | `record` | No | No |
534
+ | `updating` | `id, data` | Yes | Yes |
535
+ | `updated` | `record` | No | No |
536
+ | `deleting` | `id` | Yes | No |
537
+ | `deleted` | `id` | No | No |
538
+ | `restoring` | `id` | Yes | No |
539
+ | `restored` | `record` | No | No |
540
+
541
+ > Use `Model.create()`/`Model.update()`/`Model.delete()` to trigger events.
542
+ > `Model.query().create()` does NOT fire events.
543
+
544
+ ---
545
+
546
+ ## toJSON()
547
+
548
+ `toJSON()` applies casts, accessors, visible/hidden filtering, and appends:
549
+
550
+ ```ts
551
+ class User extends Model {
552
+ static override hidden = ['password']
553
+ static override casts = { isAdmin: 'boolean' }
554
+ static override appends = ['fullName']
555
+ static override attributes = {
556
+ fullName: Attribute.make({ get: (_, a) => `${a['firstName']} ${a['lastName']}` }),
557
+ }
558
+ }
559
+
560
+ JSON.stringify(user)
561
+ // { "name": "Alice", "isAdmin": true, "fullName": "Alice Smith" }
562
+ // password excluded, isAdmin cast to boolean, fullName computed
563
+ ```
564
+
565
+ ---
566
+
567
+ ## ModelRegistry
568
+
569
+ Low-level registry used by adapters and the ORM itself.
570
+
571
+ ```ts
572
+ import { ModelRegistry } from '@rudderjs/orm'
573
+
574
+ ModelRegistry.set(adapter) // called by provider packages
575
+ ModelRegistry.get() // current adapter (null if none)
576
+ ModelRegistry.getAdapter() // adapter or throw
577
+ ModelRegistry.reset() // clear (for tests)
578
+ ```
579
+
580
+ ---
581
+
582
+ ## API Reference
583
+
584
+ | Export | Kind | Description |
585
+ |---|---|---|
586
+ | `Model` | Abstract class | Base class for all models |
587
+ | `ModelRegistry` | Class | Global ORM adapter registry |
588
+ | `Attribute` | Class | Accessor/mutator definition |
589
+ | `JsonResource` | Abstract class | API resource transformation |
590
+ | `ResourceCollection` | Class | Collection of resources with pagination |
591
+ | `ModelCollection` | Class | Typed array wrapper with ORM operations |
592
+ | `ModelFactory` | Abstract class | Factory for testing |
593
+ | `sequence` | Function | Cycling/indexed value generator |
594
+ | `Hidden` | Decorator | Mark property as hidden |
595
+ | `Visible` | Decorator | Mark property as visible |
596
+ | `Appends` | Decorator | Append accessor to JSON output |
597
+ | `Cast` | Decorator | Apply a cast type to a property |
598
+ | `CastUsing` | Interface | Custom cast class contract |
599
+ | `CastDefinition` | Type | Built-in cast name or custom cast class |
600
+ | `QueryBuilder<T>` | Interface | Fluent query builder contract |
601
+ | `OrmAdapter` | Interface | Adapter contract |
602
+ | `PaginatedResult<T>` | Interface | Paginated result shape |
603
+ | `ModelEvent` | Type | Observer event names |
604
+ | `ModelObserver` | Interface | Observer class contract |