Models

A complete reference for BaseModel: every method, option, and feature of tekir's active-record ORM layer.

Defining a Model

Generate a model with the CLI:

tekir make:model User

Every model extends BaseModel and declares a static table name. Table structure is defined in migrations: the model only declares behavior (hidden fields, casts, timestamps, hooks, relations) and typed properties via declare.

app/models/user.ts
import { BaseModel, hasMany, scope, type Relation } from '@tekir/db'
import { hash } from '#services'
import { Post } from './post'

export class User extends BaseModel {
  static table = 'users'
  static timestamps = true
  static hidden = ['password']
  static fillable = ['name', 'email', 'password', 'role']
  static casts = { metadata: 'json' as const, isActive: 'boolean' as const }

  static relations: Record<string, Relation> = {
    posts: hasMany(() => Post)
  }

  static hooks = {
    beforeSave: [async (user: User) => {
      if (user.isDirty('password')) {
        user.password = await hash.make(user.password)
      }
    }]
  }

  static admins = scope((q) => q.where('role', 'admin'))

  static appends = ['isAdmin']
  get isAdmin(): boolean { return this.role === 'admin' }

  declare id: number
  declare name: string
  declare email: string
  declare password: string
  declare role: string
  declare isActive: boolean
  declare metadata: Record<string, unknown> | null
  declare createdAt: string
  declare updatedAt: string | null
  declare posts: Post[]
}

Prefer decorators? Install @tekir/db-decorators for the same functionality with a decorator API:

app/models/user.ts
// Same model using @tekir/db-decorators (optional, bun add @tekir/db-decorators)
import { BaseModel, scope } from '@tekir/db'
import {
  table, timestamps, hidden, cast, fillable,
  HasMany, BeforeSave
} from '@tekir/db-decorators'
import { hash } from '#services'
import { Post } from './post'

@table('users')
@timestamps()
export class User extends BaseModel {
  @hidden() declare password: string
  @cast('json') declare metadata: Record<string, unknown> | null
  @cast('boolean') declare isActive: boolean
  @fillable() declare name: string
  @fillable() declare email: string

  @HasMany(() => Post)
  declare posts: Post[]

  @BeforeSave()
  static async hashPassword(user: User) {
    if (user.isDirty('password')) {
      user.password = await hash.make(user.password)
    }
  }

  static admins = scope((q) => q.where('role', 'admin'))
  static appends = ['isAdmin']
  get isAdmin(): boolean { return this.role === 'admin' }

  declare id: number
  declare role: string
  declare createdAt: string
  declare updatedAt: string | null
}

Both styles produce identical static properties, they are fully interchangeable.

Configuration

Models are configured through static properties. Table structure is defined in migrations: the model only declares behavior.

export class Post extends BaseModel {
  static table = 'posts'

  // Override the primary key column name (default: 'id')
  static primaryKey = 'postId'

  // Name of the database connection from config/database.ts to use
  // (omit to use the default connection)
  static connection = 'analytics'

  // Enable soft deletes: requires a deletedAt column in schema
  static softDeletes = true

  // Mass-assignment whitelist: only these keys are accepted by create/fill/merge
  static fillable = ['title', 'body', 'userId', 'status']

  // Mass-assignment blacklist: everything except these keys is accepted
  // (ignored when fillable is also set)
  static guarded = ['id', 'deletedAt']

  // Global attribute casting (column-level cast takes precedence)
  static casts = {
    isPublished: 'boolean',
    metadata:    'json',
    score:       'float'
  }

  // Computed attributes appended to toJSON() / serialize()
  // Define matching getters on the class
  static appends = ['excerpt']
  get excerpt(): string { return (this.body as string).slice(0, 100) }

  // Global scopes automatically applied to every query on this model
  static globalScopes = {
    active: (q: any) => q.where('isActive', 1)
  }

  // Relationship names whose parent timestamps should be touched on save
  static touches = ['user']

  declare id: number
  declare title: string
  declare body: string
  declare userId: number
  declare status: string
}
  • table: database table name (required)
  • primaryKey: primary key column (default: 'id')
  • timestamps: auto-manage createdAt and updatedAt
  • hidden: fields excluded from toJSON()
  • fillable: whitelist for mass assignment
  • guarded: blacklist for mass assignment
  • casts: type casting: 'boolean' | 'json' | 'integer' | 'float' | 'date' or a function
  • softDeletes: destroy() sets deletedAt instead of deleting
  • appends: computed getter names included in serialization
  • globalScopes: query modifiers applied to every query
  • touches: parent relations whose timestamps update on save
  • hooks: lifecycle callbacks (see below)

Creating Records

// Insert a single record, returns a hydrated model instance
const user = await User.create({
  name:     'Ali',
  email:    '[email protected]',
  password: 'secret'
})
console.log(user.id)        // auto-assigned primary key
console.log(user.$isPersisted) // true

// Insert multiple records: calls create() for each, firing hooks individually
const [alice, bob] = await User.createMany([
  { name: 'Alice', email: '[email protected]', password: 'a' },
  { name: 'Bob',   email: '[email protected]',   password: 'b' }
])

create() runs mass-assignment filtering, applies casts for serialization, sets autoCreate timestamps, fires beforeSave + beforeCreate hooks, inserts the row, then fires afterCreate + afterSave. The returned instance has $isPersisted = true and a populated $original snapshot.

Reading Records

// Find by primary key, returns instance or null
const user = await User.find(1)

// Find by primary key: throws ModelNotFoundError (404) if not found
const user = await User.findOrFail(1)

// Find by any column: returns first match or null
const user = await User.findBy('email', '[email protected]')

// Find by any column: throws if not found
const user = await User.findByOrFail('email', '[email protected]')

// Find multiple by primary key array
const users = await User.findMany([1, 2, 3])

// Find multiple by a single column value
const published = await Post.findManyBy('status', 'published')

// First record or null
const first = await User.first()

// First record or throw
const first = await User.firstOrFail()

// All records (respects soft deletes and global scopes)
const all = await User.all()

All read methods respect softDeletes (exclude rows where deletedAt IS NOT NULL) and globalScopes automatically, except Model.query() which gives you raw access.

Where Queries

where(col, value) returns a query builder so you can chain further conditions or ordering before calling .all() / .get().

// Simple column = value filter, returns a query builder
const admins = await User.where('role', 'admin').all()

// Chain methods for complex conditions
const rows = await User.where('role', 'admin')
  .where('id', '>', 10)
  .limit(20)
  .all()

// count(): total records (respects soft deletes & global scopes)
const total = await User.count()

// countBy(): count records matching a column value
const published = await Post.countBy('status', 'published')

// exists(): boolean check
const taken = await User.exists('email', '[email protected]')

Aggregates

// Sum a column
const revenue = await Order.sum('amount')

// Average of a column
const avgPrice = await Product.avg('price')

// Minimum value
const cheapest = await Product.min('price')

// Maximum value
const mostExpensive = await Product.max('price')

// All aggregates respect soft deletes and global scopes

Pagination

const result = await Post.paginate(1, 10)

// result.data   : array of Post instances for this page
// result.meta   : pagination metadata
// {
//   total:    42,
//   page:     1,
//   perPage:  10,
//   lastPage: 5,
//   hasMore:  true,
// }

Pass page (1-based) and perPage. The result object contains the model instances in data and a meta object with total, page, perPage, lastPage, and hasMore.

Bulk Operations

pluck

// Extract a single column as a plain array
const emails = await User.pluck('email')
// ['[email protected]', '[email protected]', ...]

chunk

// Process 500 records at a time, useful for large datasets
await User.chunk(500, async (users) => {
  for (const user of users) {
    await sendEmail(user)
  }
})

chunk() fetches records in pages of size and calls your callback for each page. Stops automatically when the last page has fewer rows than size.

truncate

// Delete ALL records from the table (bypasses soft deletes)
await Post.truncate()

Updating Records

// Update by primary key, returns updated instance
const user = await User.update(1, { name: 'Updated Name' })

// Update multiple records matching a condition
await Post.updateWhere({ status: 'draft' }, { status: 'archived' })

update() applies mass-assignment filtering, sets autoUpdate timestamps, fires beforeSave + beforeUpdate hooks, runs the SQL, then fires afterUpdate + afterSave.

Deleting Records

// Delete by primary key, soft deletes if softDeletes: true
await User.destroy(1)

// Delete multiple matching a condition
await Post.destroyWhere({ createdAt: ['<', '2020-01-01'] })

When softDeletes is enabled, destroy() and destroyWhere() set deletedAt to the current timestamp instead of removing the row. Use forceDelete() on an instance to bypass soft deletes.

Idempotent Methods

// Find first match, or create if not found
// First arg: search criteria. Second arg (optional): extra create attributes.
const user = await User.firstOrCreate(
  { email: '[email protected]' },
  { name: 'Ali', password: 'hashed' }
)

// Find first match and update: or create if not found
const user = await User.updateOrCreate(
  { email: '[email protected]' },
  { name: 'Ali Updated' }
)

// Find first match: or return an unsaved instance (does NOT insert)
const user = await User.firstOrNew({ email: '[email protected]' }, { name: 'Ali' })
if (!user.$isPersisted) {
  await user.save()
}
  • firstOrCreate(search, create?): find or insert. Returns the persisted instance.
  • updateOrCreate(search, values): find and update, or insert.
  • firstOrNew(search, defaults?): find or build an unsaved instance. Check $isPersisted before calling save().

Instance Methods

save()

// Mutate then save, inserts if new, updates dirty fields if persisted
const user = await User.find(1)
user.name = 'New Name'
await user.save()
// Only the changed fields are sent in the UPDATE statement

On an unsaved instance ($isPersisted = false), save() calls create(). On a persisted instance, it computes the dirty set and calls update() with only the changed fields. Returns this for chaining. Also triggers touches on parent relations.

merge() and fill()

// merge(), assign multiple attributes at once, respects fillable (does NOT persist)
user.merge({ name: 'Ali', role: 'admin' })
await user.save()

// fill(): like merge(), but only touches keys defined in schema (does NOT persist)
user.fill({ name: 'Ali', email: '[email protected]' })
await user.save()

merge() assigns any attribute (respecting fillable/ guarded) without touching other keys. fill() is stricter, it only touches keys present in schema. Neither method persists.

delete() and forceDelete()

// delete(), soft deletes if softDeletes: true, otherwise hard deletes
await user.delete()

// forceDelete(): permanently removes the row, ignoring soft deletes
await user.forceDelete()

fresh() and refresh()

// fresh(), returns a NEW instance fetched from the DB (does not mutate current)
const freshUser = await user.fresh()

// refresh(): reloads the current instance in place from the DB
await user.refresh()
console.log(user.name) // latest value from the database

replicate()

// replicate(), clone without primary key or timestamps (not persisted)
const clone = user.replicate()
clone.email = '[email protected]'
await clone.save()  // inserts as new record

// Exclude specific columns from the clone
const clone = user.replicate(['email'])

The clone excludes the primary key and all autoCreate / autoUpdate columns. Pass an array of additional column names to exclude.

Dirty Tracking

tekir tracks the state of every persisted instance against its $original snapshot taken at load time.

const user = await User.find(1)
// user.name is currently 'Ali'

user.name = 'Veli'

// $dirty: object of changed key-value pairs
console.log(user.$dirty)        // { name: 'Veli' }

// $isDirty: true if anything changed
console.log(user.$isDirty)      // true

// $isClean: opposite of $isDirty
console.log(user.$isClean)      // false

// isDirty(col): check a specific column
console.log(user.isDirty('name'))   // true
console.log(user.isDirty('email'))  // false

// isClean(col)
console.log(user.isClean('email'))  // true

// getOriginal(col): value before the change
console.log(user.getOriginal('name'))  // 'Ali'

// getOriginal(): all original values
console.log(user.getOriginal())

// After saving...
await user.save()

// $changes: what was in the dirty set on the last save
console.log(user.$changes)         // { name: 'Veli' }

// wasChanged(col): did this column change on the last save?
console.log(user.wasChanged('name'))  // true
console.log(user.wasChanged('email')) // false
  • $dirty: object of currently changed key/value pairs
  • $isDirty: true if any attribute changed
  • $isClean: opposite of $isDirty
  • isDirty(col?): check a specific column, or overall
  • isClean(col?): opposite of isDirty
  • getOriginal(col?): original value of a column (or all originals)
  • $changes: what was dirty at the time of the last save()
  • wasChanged(col?): did this column change in the last save?

Increment & Decrement

Both static and instance variants are available. The static form takes a primary key as the first argument; the instance form operates on the current record.

// Static, increment by 1
await Post.increment(1, 'views')

// Static: increment by custom amount
await Post.increment(1, 'views', 5)

// Static: decrement
await Product.decrement(1, 'stock')
await Product.decrement(1, 'stock', 3)

// Instance: increments and updates the in-memory value immediately
await post.increment('views')
await post.increment('views', 5)

await product.decrement('stock')
await product.decrement('stock', 3)

The instance methods also update the in-memory value and sync $original so dirty tracking stays accurate.

Soft Deletes

Enable soft deletes by setting static softDeletes = true and adding a deletedAt column to the schema.

// Enable soft deletes, requires a deleted_at column in your migration
export class Post extends BaseModel {
  static table = 'posts'
  static softDeletes = true

  declare id: number
  declare title: string
  declare deletedAt: string | null
}
// destroy() sets deletedAt instead of removing the row
await Post.destroy(1)

// Regular queries automatically exclude soft-deleted rows
const posts = await Post.all()       // deletedAt IS NULL
const count = await Post.count()     // same filter applies

// Include soft-deleted rows
const allPosts = await Post.withTrashed().all()

// Only soft-deleted rows
const trashed = await Post.onlyTrashed().all()

// Check if an instance is soft-deleted
console.log(post.trashed())    // true / false

// Restore a soft-deleted instance
await post.restore()
console.log(post.trashed())    // false

// Permanently remove a soft-deleted record
await post.forceDelete()

Serialization

Every model instance can be converted to a plain object for JSON responses. Hidden columns are excluded and appends getters are included automatically.

export class User extends BaseModel {
  static table = 'users'
  static hidden = ['password']
  static appends = ['isAdmin']

  declare id: number
  declare name: string
  declare role: string
  declare password: string

  get isAdmin(): boolean {
    return this.role === 'admin'
  }
}
// toJSON(), shorthand for serialize(), called automatically by JSON.stringify
const json = user.toJSON()

// serialize(): plain object, respects hidden columns and appends
const obj = user.serialize()

// serialize with options
user.serialize({ fields: ['id', 'name'] })   // only include listed fields
user.serialize({ omit: ['email'] })           // exclude listed fields

// makeVisible: temporarily expose hidden columns on this instance
user.makeVisible(['password'])
user.toJSON()  // now includes password

// makeHidden: temporarily hide additional columns on this instance
user.makeHidden(['email'])
user.toJSON()  // excludes email

// Static helper
User.serialize(user, { omit: ['password'] })

Lifecycle Hooks

Define hooks via static hooks: an object mapping event names to arrays of handler functions. Multiple hooks per event run in array order. All hooks can be async.

import { hash } from '#services'

export class User extends BaseModel {
  static table = 'users'

  static hooks = {
    // Runs before INSERT or UPDATE
    beforeSave: [
      async (user: User) => {
        if (user.isDirty('password')) {
          user.password = await hash.make(user.password)
        }
      },
      async (user: User) => {
        if (user.name) user.name = user.name.trim()
      }
    ],

    // Runs after INSERT
    afterCreate: [
      async (user: User) => {
        await mailer.send('welcome', { to: user.email })
      }
    ],

    // Runs after UPDATE
    afterUpdate: [
      async (user: User) => {
        await cache.forget(`user:${user.id}`)
      }
    ],

    // Runs before DELETE
    beforeDelete: [
      async (payload: { id: number }) => {
        const user = await User.find(payload.id)
        if (user?.role === 'admin') throw new Error('Cannot delete admin')
      }
    ]
  }

  declare id: number
  declare name: string
  declare email: string
  declare password: string
  declare role: string
}
// Available hook events:
//
//  beforeCreate / afterCreate  : values object / inserted row
//  beforeUpdate / afterUpdate  : update values / updated row
//  beforeSave   / afterSave    : fires on both create and update
//  beforeDelete / afterDelete  : { id } object
//  beforeFind   / afterFind    : the id / the raw row
//  beforeFetch  / afterFetch   : null / the raw rows array
//
// Multiple hooks per event run in array order. All hooks can be async.

With @tekir/db-decorators, the same hooks can be written as decorated static methods:

import { BaseModel } from '@tekir/db'
import { hash } from '#services'
import { BeforeSave, AfterCreate, BeforeDelete } from '@tekir/db-decorators'

export class User extends BaseModel {
  static table = 'users'

  @BeforeSave()
  static async hashPassword(user: User) {
    if (user.isDirty('password')) {
      user.password = await hash.make(user.password)
    }
  }

  @AfterCreate()
  static async sendWelcome(user: User) {
    await mailer.send('welcome', { to: user.email })
  }

  @BeforeDelete()
  static async preventAdmin(payload: { id: number }) {
    const user = await User.find(payload.id)
    if (user?.role === 'admin') throw new Error('Cannot delete admin')
  }

  declare id: number
  declare email: string
  declare password: string
  declare role: string
}

Query Scopes

Scopes are reusable query fragments defined as static properties using the scope() helper. They receive the query builder as the first argument and any number of user-defined parameters after that.

import { scope } from '@tekir/db'

export class Post extends BaseModel {
  static table = 'posts'

  // Simple scope
  static published = scope((q) => q.where('status', 'published'))

  // Scope with parameters
  static forUser = scope((q, userId: number) => q.where('userId', userId))

  // Multiple conditions
  static recentPublished = scope((q) => {
    q.where('status', 'published')
    q.orderBy('createdAt', 'desc')
    q.limit(10)
  })
}
// Call a scope directly, returns a query builder
const posts = await Post.published.call(Post).all()
// or use withScope() for named access:

// withScope(name, ...args): apply a named scope and get the query builder back
const published = await Post.withScope('published').all()

// withScope with arguments
const userPosts = await Post.withScope('forUser', userId).all()

// Chain after withScope
const recent = await Post.withScope('published')
  .limit(5)
  .orderBy('createdAt', 'desc')
  .all()

withoutTimestamps

Wrap any write operations in withoutTimestamps() to prevent tekir from touching autoCreate / autoUpdate columns during that block.

// Run operations without touching autoCreate / autoUpdate columns
await User.withoutTimestamps(async () => {
  await User.increment(1, 'loginCount')
  await User.update(1, { lastActivity: someOldDate })
})

Raw Query Builder Access

Model.query() bypasses all ORM conveniences (global scopes, soft deletes, hooks) and returns a raw query builder. Use it when you need full control.

import { db } from '#services'

// Raw SQL: full control, no ORM overhead
const admins = await db.query('SELECT * FROM users WHERE role = ?', ['admin'])
const user = await db.queryOne('SELECT * FROM users WHERE id = ?', [1])

// Model.query(): bypasses global scopes and soft deletes
const rows = await User.query().where('role', 'admin').all()

Drizzle (Advanced)

Under the hood, tekir uses Drizzle ORM as its query builder. For complex queries (joins, subqueries, aggregations, raw SQL expressions), you can access the Drizzle instance directly via db.drizzle. This is optional and most apps will never need it.

import { db } from '#services'
import { eq, and, gt, inArray, sql } from '@tekir/db'
import { User } from '~/models/user'

// Access the Drizzle query builder for complex queries
const drizzle = db.drizzle

// Type-safe select with joins, subqueries, aggregations
const rows = await drizzle
  .select()
  .from(User.$table)
  .where(and(eq(User.$table.role, 'admin'), gt(User.$table.id, 10)))

// Insert / Update / Delete via Drizzle
await drizzle.insert(User.$table).values({ name: 'Ali', email: '[email protected]' })
await drizzle.update(User.$table).set({ role: 'admin' }).where(eq(User.$table.id, 1))
await drizzle.delete(User.$table).where(eq(User.$table.id, 1))

// Raw SQL expressions within Drizzle
const result = await drizzle
  .select({ count: sql`COUNT(*)` })
  .from(User.$table)
  .where(eq(User.$table.role, 'admin'))

// Drizzle transaction with full builder access
await drizzle.transaction(async (tx) => {
  await tx.insert(User.$table).values({ name: 'New User', email: '[email protected]' })
})

@tekir/db re-exports common Drizzle operators for convenience:

import { eq, ne, gt, gte, lt, lte, and, or, not, like,
  inArray, notInArray, isNull, isNotNull, between,
  asc, desc, count, sum, avg, min, max, sql } from '@tekir/db'