AvelonDocs

@avelon

orm

@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

MethodSignatureDescription
query(table: string, driver?: DatabaseDriver) => QueryBuilderStarts a fluent builder for a table.
QueryBuilder.select(...columns: string[]) => thisSets the projection.
QueryBuilder.where`(column: string, opOrValue: CompareOp \unknown, value?: unknown) => this`Adds a comparison predicate.
QueryBuilder.wherePredicate(predicate: Predicate) => thisAdds an arbitrary predicate.
QueryBuilder.whereNull(column: string) => thisConstrains a column to be null.
QueryBuilder.whereNotNull(column: string) => thisConstrains a column to be non-null.
QueryBuilder.whereIn(column: string, values: readonly unknown[]) => thisConstrains 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) => thisOrders descending by a timestamp column.
QueryBuilder.limit(count: number) => thisLimits rows.
QueryBuilder.offset(count: number) => thisOffsets rows.
QueryBuilder.with`(relation: string \RelationLoad) => this`Eager-loads a relation method or descriptor.
QueryBuilder.ward(predicate: WardInput) => thisInjects a ward predicate.
QueryBuilder.unwarded() => thisSkips registered ward injection.
QueryBuilder.withTrashed() => thisIncludes soft-deleted rows.
QueryBuilder.toIR(mode?: QueryIR['mode']) => QueryIRBuilds 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?) => QueryBuilderStarts 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) => QueryBuilderStarts a query warded for an actor.
Model.withTrashed() => QueryBuilderStarts a query that includes soft-deleted rows.
Model.fill(values: Row, raw?: boolean) => thisCopies 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?) => RelationLoadBuilds a belongsTo descriptor.
Model.hasOne(related, foreignKey, localKey?) => RelationLoadBuilds a hasOne descriptor.
Model.hasMany(related, foreignKey, localKey?) => RelationLoadBuilds a hasMany descriptor.
Scrivener.unwarded(model) => { query: () => QueryBuilder }Starts an unwarded query.
ModelLifecycleclass ModelLifecycle extends EventHook name, model name, and instance for observers.
ModelHooktype`'creating' \'created' \'updating' \'updated' \'saving' \'saved' \'deleting' \'deleted' \'restored'`
Pageinterface 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,
})