@avelon/orm
@avelon/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.
Installation
bun add @avelon/orm
Basic Usage
import { Model } from '@avelon/orm'
import { defineConfig } from '@avelon/core'
import type { DatabaseDriver } from '@avelon/core'
export function boot(database: DatabaseDriver) {
defineConfig({
name: 'app',
drivers: { database },
})
}
export class Post extends Model {
static override table = 'posts'
static override fillable = ['title', 'body', 'user_id']
static override casts = { published_at: 'datetime' } as const
static override timestamps = true
static override softDeletes = true
declare id: string
declare title: string
declare body: string
declare user_id: string
declare published_at: Date | null
author() {
return this.belongsTo(User, 'user_id')
}
static published() {
return this.query().whereNotNull('published_at')
}
}
export class User extends Model {
static override table = 'users'
static override fillable = ['email', 'name']
}
export async function latestPublished() {
return Post.published().with('author').latest('published_at').paginate(15)
}
Query Builder
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.
Models
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.
Lifecycle Hooks
Writes dispatch ModelLifecycle on the same event bus as application events. A creating listener that returns false (or calls stopPropagation) aborts the insert.
import { Events, defineListener } from '@avelon/core'
import { ModelLifecycle } from '@avelon/orm'
Events.listen(
ModelLifecycle,
defineListener({
handle: (event) => {
if (event.hook === 'creating' && event.modelName === 'Post') return false
},
}),
)
Method Reference
| Method | Signature | Description | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
query | (table: string, driver?: DatabaseDriver) => QueryBuilder | Starts a fluent builder for a table. | ||||||||
QueryBuilder.select | (...columns: string[]) => this | Sets the projection. | ||||||||
QueryBuilder.where | `(column: string, opOrValue: CompareOp \ | unknown, value?: unknown) => this` | Adds a comparison predicate. | |||||||
QueryBuilder.wherePredicate | (predicate: Predicate) => this | Adds an arbitrary predicate. | ||||||||
QueryBuilder.whereNull | (column: string) => this | Constrains a column to be null. | ||||||||
QueryBuilder.whereNotNull | (column: string) => this | Constrains a column to be non-null. | ||||||||
QueryBuilder.whereIn | (column: string, values: readonly unknown[]) => this | Constrains a column to a list. | ||||||||
QueryBuilder.orWhere | `(column: string, opOrValue: CompareOp \ | unknown, value?: unknown) => this` | ORs a comparison with accumulated predicates. | |||||||
QueryBuilder.orderBy | `(column: string, direction?: 'asc' \ | 'desc', nulls?: 'first' \ | 'last') => this` | Orders results. | ||||||
QueryBuilder.latest | (column?: string) => this | Orders descending by a timestamp column. | ||||||||
QueryBuilder.limit | (count: number) => this | Limits rows. | ||||||||
QueryBuilder.offset | (count: number) => this | Offsets rows. | ||||||||
QueryBuilder.with | `(relation: string \ | RelationLoad) => this` | Eager-loads a relation method or descriptor. | |||||||
QueryBuilder.ward | (predicate: WardInput) => this | Injects a ward predicate. | ||||||||
QueryBuilder.unwarded | () => this | Skips registered ward injection. | ||||||||
QueryBuilder.withTrashed | () => this | Includes soft-deleted rows. | ||||||||
QueryBuilder.toIR | (mode?: QueryIR['mode']) => QueryIR | Builds serializable IR. | ||||||||
QueryBuilder.get | () => Promise<readonly TModel[]> | Executes a select and hydrates models. | ||||||||
QueryBuilder.first | `() => Promise<TModel \ | null>` | Returns the first hydrated model. | |||||||
QueryBuilder.count | () => Promise<number> | Executes a count. | ||||||||
QueryBuilder.paginate | (perPage: number, page?: number) => Promise<Page<TModel>> | Counts and selects one page. | ||||||||
QueryBuilder.insert | `(values: Row \ | Row[], returning?: string[] \ | '*') => Promise<QueryResult>` | Inserts rows. | ||||||
QueryBuilder.update | `(values: Row, returning?: string[] \ | '*') => Promise<QueryResult>` | Updates matching rows. | |||||||
QueryBuilder.delete | `(returning?: string[] \ | '*') => Promise<QueryResult>` | Deletes or soft-deletes matching rows. | |||||||
Model.query | (driver?) => QueryBuilder | Starts a warded query for the model. | ||||||||
Model.find | `(id: string \ | number) => Promise<TModel \ | null>` | Finds by primary key. | ||||||
Model.findOrFail | `(id: string \ | number) => Promise<TModel>` | Finds by primary key or throws NotFound. | |||||||
Model.create | (values: Row) => Promise<TModel> | Mass-assigns fillable attributes and saves. | ||||||||
Model.forActor | (actor: GateActor) => QueryBuilder | Starts a query warded for an actor. | ||||||||
Model.withTrashed | () => QueryBuilder | Starts a query that includes soft-deleted rows. | ||||||||
Model.fill | (values: Row, raw?: boolean) => this | Copies fillable attributes onto the instance. | ||||||||
Model.save | (driver?) => Promise<this> | Persists the instance and fires lifecycle hooks. | ||||||||
Model.update | (values: Row) => Promise<this> | Fills and saves. | ||||||||
Model.delete | (driver?) => Promise<void> | Deletes or soft-deletes the instance. | ||||||||
Model.restore | (driver?) => Promise<this> | Clears deleted_at on a soft-deleted instance. | ||||||||
Model.belongsTo | (related, foreignKey, ownerKey?) => RelationLoad | Builds a belongsTo descriptor. | ||||||||
Model.hasOne | (related, foreignKey, localKey?) => RelationLoad | Builds a hasOne descriptor. | ||||||||
Model.hasMany | (related, foreignKey, localKey?) => RelationLoad | Builds a hasMany descriptor. | ||||||||
Scrivener.unwarded | (model) => { query: () => QueryBuilder } | Starts an unwarded query. | ||||||||
ModelLifecycle | class ModelLifecycle extends Event | Hook name, model name, and instance for observers. | ||||||||
ModelHook | type | `'creating' \ | 'created' \ | 'updating' \ | 'updated' \ | 'saving' \ | 'saved' \ | 'deleting' \ | 'deleted' \ | 'restored'` |
Page | interface Page<TModel> | data, total, perPage, page, and lastPage from paginate. |
Testing
Point defineConfig at FakeDatabase from @avelon/conformance and assert on toIR() output or hydrated models.
import { defineConfig } from '@avelon/core'
import { FakeDatabase } from '@avelon/conformance'
import { Model } from '@avelon/orm'
defineConfig({ name: 'test', drivers: { database: new FakeDatabase() } })
export class User extends Model {
static override table = 'assay_users'
static override fillable = ['id', 'email', 'name', 'age', 'nickname']
}
await User.create({
id: 'u1',
email: 'one@example.test',
name: 'One',
age: 20,
nickname: null,
})