@avelonjs/orm 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryan Yannelli
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,153 @@
1
+ # @avelonjs/orm
2
+
3
+ `@avelonjs/orm` is Scrivener, Avelon's model layer. It builds serializable `QueryIR` and never imports a database vendor. Reach for this package when application code needs models, fluent queries, ward injection, relation loads, casts, timestamps, soft deletes, or lifecycle hooks.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ bun add @avelonjs/orm
9
+ ```
10
+
11
+ ## Basic Usage
12
+
13
+ ```ts
14
+ import { Model } from '@avelonjs/orm'
15
+ import { defineConfig } from '@avelonjs/core'
16
+ import type { DatabaseDriver } from '@avelonjs/core'
17
+
18
+ export function boot(database: DatabaseDriver) {
19
+ defineConfig({
20
+ name: 'app',
21
+ drivers: { database },
22
+ })
23
+ }
24
+
25
+ export class Post extends Model {
26
+ static override table = 'posts'
27
+ static override fillable = ['title', 'body', 'user_id']
28
+ static override casts = { published_at: 'datetime' } as const
29
+ static override timestamps = true
30
+ static override softDeletes = true
31
+
32
+ declare id: string
33
+ declare title: string
34
+ declare body: string
35
+ declare user_id: string
36
+ declare published_at: Date | null
37
+
38
+ author() {
39
+ return this.belongsTo(User, 'user_id')
40
+ }
41
+
42
+ static published() {
43
+ return this.query().whereNotNull('published_at')
44
+ }
45
+ }
46
+
47
+ export class User extends Model {
48
+ static override table = 'users'
49
+ static override fillable = ['email', 'name']
50
+ }
51
+
52
+ export async function latestPublished() {
53
+ return Post.published().with('author').latest('published_at').paginate(15)
54
+ }
55
+ ```
56
+
57
+ ## Query Builder
58
+
59
+ The builder accumulates predicates, ordering, limits, relations, and wards, then emits `QueryIR` for the configured database driver. `where('age', 20)` equals `where('age', '=', 20)`. `orWhere` wraps the accumulated predicates in an `or` node. `paginate` uses count mode for the total.
60
+
61
+ ## Models
62
+
63
+ Models declare a table, fillable attributes, optional casts, timestamps, and soft deletes. Use `declare` for attributes so hydration is not overwritten by emitted class fields. `findOrFail` raises `NotFound`. `forActor` injects the actor's ward. `Scrivener.unwarded(Post).query()` skips ward injection and is restricted by Bailiff to errands and seeds.
64
+
65
+ ## Lifecycle Hooks
66
+
67
+ Writes dispatch `ModelLifecycle` on the same event bus as application events. A `creating` listener that returns `false` (or calls `stopPropagation`) aborts the insert.
68
+
69
+ ```ts
70
+ import { Events, defineListener } from '@avelonjs/core'
71
+ import { ModelLifecycle } from '@avelonjs/orm'
72
+
73
+ Events.listen(
74
+ ModelLifecycle,
75
+ defineListener({
76
+ handle: (event) => {
77
+ if (event.hook === 'creating' && event.modelName === 'Post') return false
78
+ },
79
+ }),
80
+ )
81
+ ```
82
+
83
+ ## Method Reference
84
+
85
+ | Method | Signature | Description |
86
+ | ----------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------- |
87
+ | `query` | `(table: string, driver?: DatabaseDriver) => QueryBuilder` | Starts a fluent builder for a table. |
88
+ | `QueryBuilder.select` | `(...columns: string[]) => this` | Sets the projection. |
89
+ | `QueryBuilder.where` | `(column: string, opOrValue: CompareOp \| unknown, value?: unknown) => this` | Adds a comparison predicate. |
90
+ | `QueryBuilder.wherePredicate` | `(predicate: Predicate) => this` | Adds an arbitrary predicate. |
91
+ | `QueryBuilder.whereNull` | `(column: string) => this` | Constrains a column to be null. |
92
+ | `QueryBuilder.whereNotNull` | `(column: string) => this` | Constrains a column to be non-null. |
93
+ | `QueryBuilder.whereIn` | `(column: string, values: readonly unknown[]) => this` | Constrains a column to a list. |
94
+ | `QueryBuilder.orWhere` | `(column: string, opOrValue: CompareOp \| unknown, value?: unknown) => this` | ORs a comparison with accumulated predicates. |
95
+ | `QueryBuilder.orderBy` | `(column: string, direction?: 'asc' \| 'desc', nulls?: 'first' \| 'last') => this` | Orders results. |
96
+ | `QueryBuilder.latest` | `(column?: string) => this` | Orders descending by a timestamp column. |
97
+ | `QueryBuilder.limit` | `(count: number) => this` | Limits rows. |
98
+ | `QueryBuilder.offset` | `(count: number) => this` | Offsets rows. |
99
+ | `QueryBuilder.with` | `(relation: string \| RelationLoad) => this` | Eager-loads a relation method or descriptor. |
100
+ | `QueryBuilder.ward` | `(predicate: WardInput) => this` | Injects a ward predicate. |
101
+ | `QueryBuilder.unwarded` | `() => this` | Skips registered ward injection. |
102
+ | `QueryBuilder.withTrashed` | `() => this` | Includes soft-deleted rows. |
103
+ | `QueryBuilder.toIR` | `(mode?: QueryIR['mode']) => QueryIR` | Builds serializable IR. |
104
+ | `QueryBuilder.get` | `() => Promise<readonly TModel[]>` | Executes a select and hydrates models. |
105
+ | `QueryBuilder.first` | `() => Promise<TModel \| null>` | Returns the first hydrated model. |
106
+ | `QueryBuilder.count` | `() => Promise<number>` | Executes a count. |
107
+ | `QueryBuilder.paginate` | `(perPage: number, page?: number) => Promise<Page<TModel>>` | Counts and selects one page. |
108
+ | `QueryBuilder.insert` | `(values: Row \| Row[], returning?: string[] \| '*') => Promise<QueryResult>` | Inserts rows. |
109
+ | `QueryBuilder.update` | `(values: Row, returning?: string[] \| '*') => Promise<QueryResult>` | Updates matching rows. |
110
+ | `QueryBuilder.delete` | `(returning?: string[] \| '*') => Promise<QueryResult>` | Deletes or soft-deletes matching rows. |
111
+ | `Model.query` | `(driver?) => QueryBuilder` | Starts a warded query for the model. |
112
+ | `Model.find` | `(id: string \| number) => Promise<TModel \| null>` | Finds by primary key. |
113
+ | `Model.findOrFail` | `(id: string \| number) => Promise<TModel>` | Finds by primary key or throws `NotFound`. |
114
+ | `Model.create` | `(values: Row) => Promise<TModel>` | Mass-assigns fillable attributes and saves. |
115
+ | `Model.forActor` | `(actor: GateActor) => QueryBuilder` | Starts a query warded for an actor. |
116
+ | `Model.withTrashed` | `() => QueryBuilder` | Starts a query that includes soft-deleted rows. |
117
+ | `Model.fill` | `(values: Row, raw?: boolean) => this` | Copies fillable attributes onto the instance. |
118
+ | `Model.save` | `(driver?) => Promise<this>` | Persists the instance and fires lifecycle hooks. |
119
+ | `Model.update` | `(values: Row) => Promise<this>` | Fills and saves. |
120
+ | `Model.delete` | `(driver?) => Promise<void>` | Deletes or soft-deletes the instance. |
121
+ | `Model.restore` | `(driver?) => Promise<this>` | Clears `deleted_at` on a soft-deleted instance. |
122
+ | `Model.belongsTo` | `(related, foreignKey, ownerKey?) => RelationLoad` | Builds a belongsTo descriptor. |
123
+ | `Model.hasOne` | `(related, foreignKey, localKey?) => RelationLoad` | Builds a hasOne descriptor. |
124
+ | `Model.hasMany` | `(related, foreignKey, localKey?) => RelationLoad` | Builds a hasMany descriptor. |
125
+ | `Scrivener.unwarded` | `(model) => { query: () => QueryBuilder }` | Starts an unwarded query. |
126
+ | `ModelLifecycle` | `class ModelLifecycle extends Event` | Hook name, model name, and instance for observers. |
127
+ | `ModelHook` | `type` | `'creating' \| 'created' \| 'updating' \| 'updated' \| 'saving' \| 'saved' \| 'deleting' \| 'deleted' \| 'restored'` |
128
+ | `Page` | `interface Page<TModel>` | `data`, `total`, `perPage`, `page`, and `lastPage` from `paginate`. |
129
+
130
+ ## Testing
131
+
132
+ Point `defineConfig` at `FakeDatabase` from `@avelonjs/conformance` and assert on `toIR()` output or hydrated models.
133
+
134
+ ```ts
135
+ import { defineConfig } from '@avelonjs/core'
136
+ import { FakeDatabase } from '@avelonjs/conformance'
137
+ import { Model } from '@avelonjs/orm'
138
+
139
+ defineConfig({ name: 'test', drivers: { database: new FakeDatabase() } })
140
+
141
+ export class User extends Model {
142
+ static override table = 'assay_users'
143
+ static override fillable = ['id', 'email', 'name', 'age', 'nickname']
144
+ }
145
+
146
+ await User.create({
147
+ id: 'u1',
148
+ email: 'one@example.test',
149
+ name: 'One',
150
+ age: 20,
151
+ nickname: null,
152
+ })
153
+ ```
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@avelonjs/orm",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Model layer and QueryIR builder for Avelon applications.",
6
+ "license": "MIT",
7
+ "author": "Ryan Yannelli <ryanyannelli@gmail.com>",
8
+ "homepage": "https://github.com/yannelli/avelon",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/yannelli/avelon.git",
12
+ "directory": "packages/orm"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/yannelli/avelon/issues"
16
+ },
17
+ "keywords": [
18
+ "avelon",
19
+ "typescript",
20
+ "orm",
21
+ "query-ir"
22
+ ],
23
+ "type": "module",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "files": [
28
+ "src",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "exports": {
33
+ ".": "./src/index.ts"
34
+ },
35
+ "scripts": {
36
+ "test": "bun test",
37
+ "typecheck": "tsc --noEmit"
38
+ },
39
+ "dependencies": {
40
+ "@avelonjs/core": "workspace:*"
41
+ },
42
+ "devDependencies": {
43
+ "@avelonjs/conformance": "workspace:*",
44
+ "@types/bun": "1.3.14",
45
+ "typescript": "5.9.3"
46
+ },
47
+ "engines": {
48
+ "bun": ">=1.3.14"
49
+ }
50
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export {
2
+ Model,
3
+ ModelLifecycle,
4
+ QueryBuilder,
5
+ query,
6
+ Scrivener,
7
+ type ModelHook,
8
+ type Page,
9
+ } from './model'
package/src/model.ts ADDED
@@ -0,0 +1,655 @@
1
+ import {
2
+ DB,
3
+ Event,
4
+ Events,
5
+ Gate,
6
+ Invalid,
7
+ NotFound,
8
+ injectWard,
9
+ resolveWard,
10
+ type CompareOp,
11
+ type DatabaseDriver,
12
+ type DatabaseTransaction,
13
+ type GateActor,
14
+ type OrderTerm,
15
+ type Predicate,
16
+ type QueryIR,
17
+ type QueryResult,
18
+ type RelationLoad,
19
+ type WardInput,
20
+ } from '@avelonjs/core'
21
+
22
+ type Row = Record<string, unknown>
23
+ type CastKind = 'datetime' | 'json' | 'number' | 'boolean'
24
+
25
+ /** Lifecycle hooks dispatched on the shared event bus. */
26
+ export type ModelHook =
27
+ | 'creating'
28
+ | 'created'
29
+ | 'updating'
30
+ | 'updated'
31
+ | 'saving'
32
+ | 'saved'
33
+ | 'deleting'
34
+ | 'deleted'
35
+ | 'restored'
36
+
37
+ /** Event fired for every model lifecycle hook so observers are ordinary listeners. */
38
+ export class ModelLifecycle extends Event {
39
+ constructor(
40
+ readonly modelName: string,
41
+ readonly hook: ModelHook,
42
+ readonly model: Model,
43
+ ) {
44
+ super()
45
+ }
46
+ }
47
+
48
+ /** Page of hydrated models returned by `paginate`. */
49
+ export interface Page<TModel> {
50
+ data: readonly TModel[]
51
+ total: number
52
+ perPage: number
53
+ page: number
54
+ lastPage: number
55
+ }
56
+
57
+ type ModelClass = typeof Model & { new (): Model }
58
+
59
+ function ctorOf(model: Model): ModelClass {
60
+ return model.constructor as ModelClass
61
+ }
62
+
63
+ function tableOf(modelClass: ModelClass): string {
64
+ return modelClass.table
65
+ }
66
+
67
+ function keyOf(modelClass: ModelClass): string {
68
+ return modelClass.key
69
+ }
70
+
71
+ function asRecord(model: Model): Row {
72
+ return model as unknown as Row
73
+ }
74
+
75
+ function read(model: Model, column: string): unknown {
76
+ return asRecord(model)[column]
77
+ }
78
+
79
+ function write(model: Model, column: string, value: unknown): void {
80
+ asRecord(model)[column] = value
81
+ }
82
+
83
+ function castIn(kind: CastKind | undefined, value: unknown): unknown {
84
+ if (kind === undefined || value === null || value === undefined) return value
85
+ switch (kind) {
86
+ case 'datetime':
87
+ return value instanceof Date ? value : new Date(String(value))
88
+ case 'json':
89
+ return typeof value === 'string' ? (JSON.parse(value) as unknown) : value
90
+ case 'number':
91
+ return typeof value === 'number' ? value : Number(value)
92
+ case 'boolean':
93
+ return Boolean(value)
94
+ }
95
+ }
96
+
97
+ function castOut(kind: CastKind | undefined, value: unknown): unknown {
98
+ if (kind === undefined || value === null || value === undefined) return value
99
+ switch (kind) {
100
+ case 'datetime':
101
+ return value instanceof Date ? value.toISOString() : value
102
+ case 'json':
103
+ return typeof value === 'string' ? value : JSON.stringify(value)
104
+ case 'number':
105
+ case 'boolean':
106
+ return value
107
+ }
108
+ }
109
+
110
+ function fillableKeys(modelClass: ModelClass, values: Row, raw: boolean): string[] {
111
+ if (raw) return Object.keys(values)
112
+ return modelClass.fillable.filter((key) => Object.hasOwn(values, key))
113
+ }
114
+
115
+ async function fire(model: Model, hook: ModelHook): Promise<boolean> {
116
+ const event = new ModelLifecycle(ctorOf(model).name, hook, model)
117
+ await Events.dispatch(event)
118
+ return !event.isPropagationStopped()
119
+ }
120
+
121
+ /** Fluent query builder that emits portable `QueryIR` and hydrates models. */
122
+ export class QueryBuilder<TModel extends Model = Model> {
123
+ readonly #model: ModelClass
124
+ readonly #driver: DatabaseDriver | DatabaseTransaction
125
+ #select: string[] | '*' = '*'
126
+ #where: Predicate[] = []
127
+ #order: OrderTerm[] = []
128
+ #relations: RelationLoad[] = []
129
+ #limit: number | undefined
130
+ #offset: number | undefined
131
+ #ward: WardInput | undefined
132
+ #unwarded = false
133
+ #withTrashed = false
134
+ #actor: GateActor | undefined
135
+
136
+ constructor(
137
+ model: ModelClass,
138
+ driver: DatabaseDriver | DatabaseTransaction = DB.driver,
139
+ options: { unwarded?: boolean; withTrashed?: boolean; actor?: GateActor } = {},
140
+ ) {
141
+ this.#model = model
142
+ this.#driver = driver
143
+ this.#unwarded = options.unwarded ?? false
144
+ this.#withTrashed = options.withTrashed ?? false
145
+ this.#actor = options.actor
146
+ }
147
+
148
+ /** Starts an unwarded builder. Restricted by Bailiff to errands and seeds. */
149
+ unwarded(): this {
150
+ this.#unwarded = true
151
+ return this
152
+ }
153
+
154
+ /** Includes soft-deleted rows. */
155
+ withTrashed(): this {
156
+ this.#withTrashed = true
157
+ return this
158
+ }
159
+
160
+ /** Restricts the projection. */
161
+ select(...columns: string[]): this {
162
+ this.#select = columns.length === 0 ? '*' : columns
163
+ return this
164
+ }
165
+
166
+ /**
167
+ * Adds a comparison predicate.
168
+ *
169
+ * `where('age', 20)` equals `where('age', '=', 20)`.
170
+ */
171
+ where(column: string, opOrValue: CompareOp | unknown, value?: unknown): this {
172
+ if (value === undefined) {
173
+ this.#where.push({ kind: 'compare', column, op: '=', value: opOrValue })
174
+ } else {
175
+ this.#where.push({
176
+ kind: 'compare',
177
+ column,
178
+ op: opOrValue as CompareOp,
179
+ value,
180
+ })
181
+ }
182
+ return this
183
+ }
184
+
185
+ /** Adds a raw predicate object. */
186
+ wherePredicate(predicate: Predicate): this {
187
+ this.#where.push(predicate)
188
+ return this
189
+ }
190
+
191
+ /** Constrains a column to be null. */
192
+ whereNull(column: string): this {
193
+ this.#where.push({ kind: 'null', column, negated: false })
194
+ return this
195
+ }
196
+
197
+ /** Constrains a column to be non-null. */
198
+ whereNotNull(column: string): this {
199
+ this.#where.push({ kind: 'null', column, negated: true })
200
+ return this
201
+ }
202
+
203
+ /** Constrains a column to a list of values. */
204
+ whereIn(column: string, values: readonly unknown[]): this {
205
+ this.#where.push({ kind: 'in', column, values: [...values], negated: false })
206
+ return this
207
+ }
208
+
209
+ /** ORs a comparison with the predicates accumulated so far. */
210
+ orWhere(column: string, opOrValue: CompareOp | unknown, value?: unknown): this {
211
+ const next: Predicate =
212
+ value === undefined
213
+ ? { kind: 'compare', column, op: '=', value: opOrValue }
214
+ : { kind: 'compare', column, op: opOrValue as CompareOp, value }
215
+ if (this.#where.length === 0) {
216
+ this.#where.push(next)
217
+ return this
218
+ }
219
+ const previous = this.#where.splice(0, this.#where.length)
220
+ this.#where.push({
221
+ kind: 'or',
222
+ predicates: [
223
+ previous.length === 1 ? (previous[0] as Predicate) : { kind: 'and', predicates: previous },
224
+ next,
225
+ ],
226
+ })
227
+ return this
228
+ }
229
+
230
+ /** Orders the result. */
231
+ orderBy(column: string, direction: 'asc' | 'desc' = 'asc', nulls?: 'first' | 'last'): this {
232
+ this.#order.push(nulls === undefined ? { column, direction } : { column, direction, nulls })
233
+ return this
234
+ }
235
+
236
+ /** Newest-first helper for timestamp columns. */
237
+ latest(column = 'created_at'): this {
238
+ return this.orderBy(column, 'desc')
239
+ }
240
+
241
+ /** Limits the result count. */
242
+ limit(count: number): this {
243
+ this.#limit = count
244
+ return this
245
+ }
246
+
247
+ /** Offsets the result window. */
248
+ offset(count: number): this {
249
+ this.#offset = count
250
+ return this
251
+ }
252
+
253
+ /** Eager-loads a relation name declared on the model, or a fully resolved descriptor. */
254
+ with(relation: string | RelationLoad): this {
255
+ this.#relations.push(typeof relation === 'string' ? this.#resolveRelation(relation) : relation)
256
+ return this
257
+ }
258
+
259
+ /** Applies a ward predicate for row scoping. */
260
+ ward(predicate: WardInput): this {
261
+ this.#ward = predicate
262
+ return this
263
+ }
264
+
265
+ /** Builds the serializable query IR without executing it. */
266
+ toIR(mode: QueryIR['mode'] = 'select'): QueryIR {
267
+ const where = [...this.#where]
268
+ if (this.#model.softDeletes && !this.#withTrashed) {
269
+ where.push({ kind: 'null', column: 'deleted_at', negated: false })
270
+ }
271
+ const base: QueryIR = {
272
+ table: tableOf(this.#model),
273
+ mode,
274
+ select: mode === 'count' ? [] : this.#select,
275
+ where,
276
+ relations: mode === 'select' ? this.#relations : [],
277
+ order: mode === 'count' ? [] : this.#order,
278
+ ...(this.#limit !== undefined && mode !== 'count' ? { limit: this.#limit } : {}),
279
+ ...(this.#offset !== undefined && mode !== 'count' ? { offset: this.#offset } : {}),
280
+ }
281
+ if (this.#unwarded) return this.#ward === undefined ? base : injectWard(base, this.#ward)
282
+ const resolved = this.#resolvedWard()
283
+ if (resolved === undefined && this.#ward === undefined) return base
284
+ return injectWard(base, this.#ward ?? resolved ?? true)
285
+ }
286
+
287
+ /** Executes a select and hydrates models. */
288
+ async get(): Promise<readonly TModel[]> {
289
+ const result = await this.#driver.execute<Row>(this.toIR('select'))
290
+ return result.rows.map((row) => hydrate(this.#model, row) as TModel)
291
+ }
292
+
293
+ /** Executes a select and returns the first model or null. */
294
+ async first(): Promise<TModel | null> {
295
+ const rows = await this.limit(1).get()
296
+ return rows[0] ?? null
297
+ }
298
+
299
+ /** Executes a count query. */
300
+ async count(): Promise<number> {
301
+ const result = await this.#driver.execute(this.toIR('count'))
302
+ return result.count ?? 0
303
+ }
304
+
305
+ /** Paginates using count plus a limited select. */
306
+ async paginate(perPage: number, page = 1): Promise<Page<TModel>> {
307
+ if (!Number.isInteger(perPage) || perPage < 1 || !Number.isInteger(page) || page < 1) {
308
+ throw new Invalid('Pagination requires positive integer perPage and page.', {
309
+ metadata: { fields: { page: ['perPage and page must be positive integers.'] } },
310
+ })
311
+ }
312
+ const total = await this.count()
313
+ const data = await this.offset((page - 1) * perPage)
314
+ .limit(perPage)
315
+ .get()
316
+ return {
317
+ data,
318
+ total,
319
+ perPage,
320
+ page,
321
+ lastPage: Math.max(1, Math.ceil(total / perPage)),
322
+ }
323
+ }
324
+
325
+ /** Inserts one or more rows. */
326
+ async insert(values: Row | Row[], returning?: string[] | '*'): Promise<QueryResult<Row>> {
327
+ return this.#driver.execute<Row>({
328
+ table: tableOf(this.#model),
329
+ mode: 'insert',
330
+ select: [],
331
+ where: [],
332
+ relations: [],
333
+ order: [],
334
+ values,
335
+ returning,
336
+ })
337
+ }
338
+
339
+ /** Updates matching rows. */
340
+ async update(values: Row, returning?: string[] | '*'): Promise<QueryResult<Row>> {
341
+ return this.#driver.execute<Row>({
342
+ ...this.toIR('update'),
343
+ mode: 'update',
344
+ select: [],
345
+ values,
346
+ returning,
347
+ })
348
+ }
349
+
350
+ /** Deletes matching rows, soft-deleting when the model enables it. */
351
+ async delete(returning?: string[] | '*'): Promise<QueryResult<Row>> {
352
+ if (this.#model.softDeletes) {
353
+ return this.update({ deleted_at: new Date().toISOString() }, returning)
354
+ }
355
+ return this.#driver.execute<Row>({
356
+ ...this.toIR('delete'),
357
+ mode: 'delete',
358
+ select: [],
359
+ returning,
360
+ })
361
+ }
362
+
363
+ #resolvedWard(): WardInput | undefined {
364
+ try {
365
+ return resolveWard(
366
+ this.#model,
367
+ 'read',
368
+ this.#actor === undefined ? Gate.actor() : this.#actor,
369
+ )
370
+ } catch {
371
+ return this.#model.ward?.(this.#actor === undefined ? Gate.actor() : this.#actor)
372
+ }
373
+ }
374
+
375
+ #resolveRelation(path: string): RelationLoad {
376
+ if (path.includes('.')) {
377
+ throw new Invalid('Nested with() paths need RelationLoad.relations.', {
378
+ metadata: { fields: { with: [`${path} is nested; pass a RelationLoad with relations.`] } },
379
+ })
380
+ }
381
+ if (path.length === 0) {
382
+ throw new Invalid('Relation name is required.', {
383
+ metadata: { fields: { with: ['Relation name is required.'] } },
384
+ })
385
+ }
386
+ const instance = new this.#model()
387
+ const method = Reflect.get(instance, path)
388
+ if (typeof method !== 'function') {
389
+ throw new Invalid(`Unknown relation ${path} on ${this.#model.name}.`, {
390
+ metadata: { fields: { with: [`${this.#model.name} has no ${path}() relation.`] } },
391
+ })
392
+ }
393
+ const descriptor = method.call(instance) as RelationLoad
394
+ return { ...descriptor, relation: path }
395
+ }
396
+ }
397
+
398
+ function hydrate(modelClass: ModelClass, row: Row): Model {
399
+ const model = new modelClass()
400
+ for (const [column, value] of Object.entries(row)) {
401
+ write(model, column, castIn(modelClass.casts[column], value))
402
+ }
403
+ model.exists = true
404
+ return model
405
+ }
406
+
407
+ function serialize(model: Model, keys: readonly string[]): Row {
408
+ const modelClass = ctorOf(model)
409
+ const values: Row = {}
410
+ for (const key of keys) {
411
+ values[key] = castOut(modelClass.casts[key], read(model, key))
412
+ }
413
+ return values
414
+ }
415
+
416
+ /** Base Scrivener model. Subclasses use `declare` for attributes, never emitted fields. */
417
+ export class Model {
418
+ /** Database table name. */
419
+ static table = ''
420
+ /** Primary key column. */
421
+ static key = 'id'
422
+ /** Mass-assignable attributes. */
423
+ static fillable: readonly string[] = []
424
+ /** Attribute casts applied on hydrate and persist. */
425
+ static casts: Readonly<Record<string, CastKind>> = {}
426
+ /** When true, `created_at` and `updated_at` are maintained. */
427
+ static timestamps = false
428
+ /** When true, `delete()` writes `deleted_at` instead of removing the row. */
429
+ static softDeletes = false
430
+ /** Optional ward factory used when no `defineWard` registration exists. */
431
+ static ward?: (actor: GateActor) => WardInput
432
+
433
+ /** Whether this instance has been persisted. */
434
+ exists = false
435
+
436
+ /** Starts a query for this model. */
437
+ static query<T extends typeof Model>(
438
+ this: T,
439
+ driver?: DatabaseDriver | DatabaseTransaction,
440
+ ): QueryBuilder<InstanceType<T>> {
441
+ return new QueryBuilder(this as ModelClass, driver ?? DB.driver)
442
+ }
443
+
444
+ /** Includes soft-deleted rows. */
445
+ static withTrashed<T extends typeof Model>(this: T): QueryBuilder<InstanceType<T>> {
446
+ return this.query().withTrashed()
447
+ }
448
+
449
+ /** Applies the model ward for the supplied actor. */
450
+ static forActor<T extends typeof Model>(
451
+ this: T,
452
+ actor: GateActor,
453
+ driver?: DatabaseDriver | DatabaseTransaction,
454
+ ): QueryBuilder<InstanceType<T>> {
455
+ return new QueryBuilder(this as ModelClass, driver ?? DB.driver, { actor })
456
+ }
457
+
458
+ /** Finds a row by primary key or returns null. */
459
+ static async find<T extends typeof Model>(
460
+ this: T,
461
+ id: string | number,
462
+ driver?: DatabaseDriver | DatabaseTransaction,
463
+ ): Promise<InstanceType<T> | null> {
464
+ return this.query(driver).where(this.key, id).first()
465
+ }
466
+
467
+ /** Finds a row by primary key or throws `NotFound`. */
468
+ static async findOrFail<T extends typeof Model>(
469
+ this: T,
470
+ id: string | number,
471
+ driver?: DatabaseDriver | DatabaseTransaction,
472
+ ): Promise<InstanceType<T>> {
473
+ const model = await this.find(id, driver)
474
+ if (model === null) {
475
+ throw new NotFound(`${this.name} was not found.`, {
476
+ metadata: { resource: this.table || this.name, identifier: id },
477
+ })
478
+ }
479
+ return model
480
+ }
481
+
482
+ /** Mass-assigns fillable attributes, persists, and returns the hydrated model. */
483
+ static async create<T extends typeof Model>(
484
+ this: T,
485
+ values: Row,
486
+ driver?: DatabaseDriver | DatabaseTransaction,
487
+ ): Promise<InstanceType<T>> {
488
+ const model = new this() as InstanceType<T>
489
+ model.fill(values)
490
+ await model.save(driver)
491
+ return model
492
+ }
493
+
494
+ /** Copies fillable attributes onto the instance. */
495
+ fill(values: Row, raw = false): this {
496
+ const modelClass = ctorOf(this)
497
+ for (const key of fillableKeys(modelClass, values, raw)) {
498
+ const value = values[key]
499
+ write(this, key, castIn(modelClass.casts[key], value))
500
+ }
501
+ return this
502
+ }
503
+
504
+ /** Persists the instance, firing lifecycle hooks. */
505
+ async save(driver?: DatabaseDriver | DatabaseTransaction): Promise<this> {
506
+ const modelClass = ctorOf(this)
507
+ const saving = await fire(this, 'saving')
508
+ if (!saving) return this
509
+ if (!this.exists) {
510
+ if (!(await fire(this, 'creating'))) return this
511
+ if (modelClass.timestamps) {
512
+ const now = new Date().toISOString()
513
+ if (read(this, 'created_at') === undefined) write(this, 'created_at', now)
514
+ write(this, 'updated_at', now)
515
+ }
516
+ const keys = persistKeys(this)
517
+ const result = await modelClass.query(driver).insert(serialize(this, keys), '*')
518
+ const row = result.rows[0]
519
+ if (row !== undefined) {
520
+ for (const [column, value] of Object.entries(row)) {
521
+ write(this, column, castIn(modelClass.casts[column], value))
522
+ }
523
+ }
524
+ this.exists = true
525
+ await fire(this, 'created')
526
+ } else {
527
+ if (!(await fire(this, 'updating'))) return this
528
+ if (modelClass.timestamps) write(this, 'updated_at', new Date().toISOString())
529
+ const keys = persistKeys(this)
530
+ const id = read(this, keyOf(modelClass))
531
+ await modelClass.query(driver).where(keyOf(modelClass), id).update(serialize(this, keys))
532
+ await fire(this, 'updated')
533
+ }
534
+ await fire(this, 'saved')
535
+ return this
536
+ }
537
+
538
+ /** Mass-assigns fillable attributes and persists. */
539
+ async update(values: Row, driver?: DatabaseDriver | DatabaseTransaction): Promise<this> {
540
+ this.fill(values)
541
+ return this.save(driver)
542
+ }
543
+
544
+ /** Deletes the instance, soft-deleting when enabled. */
545
+ async delete(driver?: DatabaseDriver | DatabaseTransaction): Promise<void> {
546
+ if (!(await fire(this, 'deleting'))) return
547
+ const modelClass = ctorOf(this)
548
+ const id = read(this, keyOf(modelClass))
549
+ await modelClass.query(driver).where(keyOf(modelClass), id).delete()
550
+ this.exists = false
551
+ await fire(this, 'deleted')
552
+ }
553
+
554
+ /** Restores a soft-deleted instance. */
555
+ async restore(driver?: DatabaseDriver | DatabaseTransaction): Promise<this> {
556
+ const modelClass = ctorOf(this)
557
+ write(this, 'deleted_at', null)
558
+ const id = read(this, keyOf(modelClass))
559
+ await modelClass.withTrashed().where(keyOf(modelClass), id).update({ deleted_at: null })
560
+ this.exists = true
561
+ await fire(this, 'restored')
562
+ return this
563
+ }
564
+
565
+ /** Declares a belongsTo relation for eager loading. */
566
+ belongsTo(
567
+ related: ModelClass,
568
+ foreignKey: string,
569
+ ownerKey = 'id',
570
+ relation = related.name.replace(/^[A-Z]/, (letter) => letter.toLowerCase()),
571
+ ): RelationLoad {
572
+ return {
573
+ relation,
574
+ kind: 'belongsTo',
575
+ table: related.table,
576
+ localKey: foreignKey,
577
+ foreignKey: ownerKey,
578
+ select: '*',
579
+ where: null,
580
+ order: [],
581
+ relations: [],
582
+ }
583
+ }
584
+
585
+ /** Declares a hasOne relation for eager loading. */
586
+ hasOne(related: ModelClass, foreignKey: string, localKey = 'id'): RelationLoad {
587
+ return {
588
+ relation: related.name.replace(/^[A-Z]/, (letter) => letter.toLowerCase()),
589
+ kind: 'hasOne',
590
+ table: related.table,
591
+ localKey,
592
+ foreignKey,
593
+ select: '*',
594
+ where: null,
595
+ order: [],
596
+ relations: [],
597
+ }
598
+ }
599
+
600
+ /** Declares a hasMany relation for eager loading. */
601
+ hasMany(related: ModelClass, foreignKey: string, localKey = 'id'): RelationLoad {
602
+ return {
603
+ relation: related.name.replace(/^[A-Z]/, (letter) => letter.toLowerCase()),
604
+ kind: 'hasMany',
605
+ table: related.table,
606
+ localKey,
607
+ foreignKey,
608
+ select: '*',
609
+ where: null,
610
+ order: [],
611
+ relations: [],
612
+ }
613
+ }
614
+ }
615
+
616
+ function persistKeys(model: Model): string[] {
617
+ const modelClass = ctorOf(model)
618
+ const keys = new Set<string>([...modelClass.fillable])
619
+ keys.add(keyOf(modelClass))
620
+ if (modelClass.timestamps) {
621
+ keys.add('created_at')
622
+ keys.add('updated_at')
623
+ }
624
+ if (modelClass.softDeletes) keys.add('deleted_at')
625
+ for (const key of Object.keys(asRecord(model))) {
626
+ if (typeof asRecord(model)[key] !== 'function' && key !== 'exists') keys.add(key)
627
+ }
628
+ return [...keys].filter((key) => read(model, key) !== undefined)
629
+ }
630
+
631
+ /** Starts a query against a table using an anonymous model class. */
632
+ export function query<TRow extends Row = Row>(
633
+ table: string,
634
+ driver?: DatabaseDriver | DatabaseTransaction,
635
+ ): QueryBuilder {
636
+ class Anonymous extends Model {
637
+ static override table = table
638
+ static override fillable: readonly string[] = []
639
+ }
640
+ Object.defineProperty(Anonymous, 'name', { value: table })
641
+ return new QueryBuilder(Anonymous, driver ?? DB.driver) as QueryBuilder<Model & TRow>
642
+ }
643
+
644
+ /** Scrivener entry for operations that Bailiff later restricts by directory. */
645
+ export const Scrivener = {
646
+ /** Returns an unwarded query for a model. Allowed only in errands and seeds. */
647
+ unwarded<T extends typeof Model>(model: T): { query: () => QueryBuilder<InstanceType<T>> } {
648
+ return {
649
+ query: () =>
650
+ new QueryBuilder(model as ModelClass, DB.driver, { unwarded: true }) as QueryBuilder<
651
+ InstanceType<T>
652
+ >,
653
+ }
654
+ },
655
+ }