Skip to content

BaseModel API Reference

BaseModel<TAttrs, TVirtuals, TRels> is the base class for all ORM models. Extend it with a typed attributes interface or type alias that mirrors your database columns. The optional TVirtuals generic (defaults to {}) types virtual attribute access through the proxy — see Proxy Access. The optional TRels generic (defaults to Record<string, unknown>) types the .relations property — see Typed Relations below.

All model-returning methods (find, create, query().first(), etc.) return proxy-wrapped instances by default. This means you can access typed attributes directly as properties — no .toObject() needed.

db is optional after configure(env)

Every method below is shown with two signatures: the explicit-db form and the auto-resolved form. After calling configure(env) once at startup you can omit db everywhereModel.create(attrs), Model.find(id), Model.query(), model.delete(). Passing db explicitly still works and takes priority over the configured default.

ts
import { BaseModel } from '@orphnet/d1-eloquent'

// Both `type` and `interface` work for attrs definitions
interface PostAttrs {
  id: string
  title: string
  user_id: string
  created_at?: string
  updated_at?: string
  deleted_at?: string
}

class Post extends BaseModel<PostAttrs> {
  static table = 'posts'
}

// Proxy-by-default: typed property access works immediately
const post = await Post.find(env.DB, postId)
console.log(post?.title)  // typed as string — no cast needed

Model Configuration

Static properties that configure ORM behavior. Set them directly on the class body.

PropertyTypeDefaultDescription
tablestringrequiredD1 table name
primaryKeystring'id'Primary key column name
softDeletesbooleanfalseEnable soft-delete via deleted_at column
timestampsbooleantrueAuto-manage created_at / updated_at
castsRecord<string, CastDefinition>undefinedAttribute type casting — see Casting guide
revisionsRevisionConfigundefinedEnable immutable audit log
revisionRedactstring[][]Fields excluded from revision data
revisionOnlystring[][]Only these fields captured in revision data
accessorsRecord<string, Attribute>undefinedAccessor/mutator definitions — see Accessors & Mutators guide
appendsstring[]undefinedVirtual attribute keys included in toObject()
hiddenstring[]undefinedAttribute keys excluded from toObject()
fillablestring[]undefinedWhitelist of keys allowed via fill()
guardedstring[]undefinedBlacklist of keys blocked via fill()
relationsRecord<string, TRelationDefinition>undefinedDeclarative relationship definitions — see Relationships API
scopesRecord<string, (q) => void>undefinedNamed query scopes — see Scopes
hooksTHooks<T>undefinedLifecycle hooks — see Lifecycle Hooks
eagerLoadersRecord<string, (db, models) => Promise<void>>{}Custom eager-load definitions for .with()
modelNamestringundefinedHuman-readable name used in exception messages (defaults to table)
connectionD1Database | stringundefinedDefault DB connection for this model

RevisionConfig Type

ts
interface RevisionConfig {
  enabled: boolean
  mode: 'diff' | 'snapshot' | 'diff+after' | 'before+after'
  includeRequestId?: boolean
}
ModeWhat is stored
'diff'Changed fields only
'snapshot'Full record after change
'diff+after'Changed fields + full record after (recommended)
'before+after'Full record before + full record after

RevisionContext Type

ts
interface RevisionContext {
  actorId?: string
  requestId?: string
  reason?: string
}

Pass this as opts.revision to save() and delete().

Static Methods

create(db, attrs)

ts
static create(attrs: TAttrs): Promise<TModel>                   // auto-resolve db
static create(db: D1Database, attrs: TAttrs): Promise<TModel>

Inserts a new row and returns a proxy-wrapped model instance with typed property access.

ts
const post = await Post.create(env.DB, {
  id: crypto.randomUUID(),
  title: 'Hello world',
  user_id: userId,
})
console.log(post.title)  // 'Hello world' — typed as string

TIP

Text primary keys (UUIDs) are the convention. Generate the id value with crypto.randomUUID() before calling create().


find(db, id)

ts
static find(id: string): Promise<TModel | null>                    // auto-resolve db
static find(db: D1Database, id: string): Promise<TModel | null>

Fetches a single row by primary key. Returns null if not found. When softDeletes is enabled, soft-deleted rows are excluded.

ts
const post = await Post.find(env.DB, postId)
if (post) {
  console.log(post.title)      // direct property access (proxy-by-default)
  console.log(post.get('title')) // typed accessor alternative
}

findOrFail(db, id)

ts
static findOrFail(db: D1Database, id: string): Promise<TModel>
static findOrFail(id: string): Promise<TModel>  // auto-resolve

Fetches a single row by primary key. Throws ModelNotFoundException if not found.

ts
import { ModelNotFoundException } from '@orphnet/d1-eloquent'

const post = await Post.findOrFail(env.DB, postId)

try {
  await Post.findOrFail('nonexistent-id')
} catch (e) {
  if (e instanceof ModelNotFoundException) {
    console.log(e.model) // "posts"
    console.log(e.id)    // "nonexistent-id"
  }
}

query(db?)

ts
static query(): QueryBuilder<TModel>                       // auto-resolve db
static query(db: D1Database): QueryBuilder<TModel>

Returns a QueryBuilder for constructing a SELECT query. All filter, shape, and terminal methods are available on the returned builder.

When db is provided, it becomes the default database for all terminal methods on this query — no need to pass db again to .get(), .first(), etc. Explicit db at a terminal call still takes priority.

ts
// Set db once at query creation
const posts = await Post.query(env.DB)
  .whereEq('user_id', userId)
  .orderBy('created_at', 'DESC')
  .limit(10)
  .get()

// Or pass db at terminal call (existing pattern still works)
const posts = await Post.query()
  .whereEq('user_id', userId)
  .get(env.DB)

See QueryBuilder reference for all chainable methods.


asOf(db, id, isoTimestamp)

ts
static asOf(
  db: D1Database,
  id: string,
  isoTimestamp: string
): Promise<TModel | null>
static asOf(id: string, isoTimestamp: string): Promise<TModel | null>  // auto-resolve db

Reconstructs the model state as it existed at the given ISO 8601 timestamp. Returns null if the record did not exist at that point.

Requires revisions.enabled = true and revisions.mode of 'diff+after' or 'before+after'. Modes 'diff' and 'snapshot' do not support reliable reconstruction.

ts
const post = await Post.asOf(env.DB, postId, '2026-01-15T12:00:00Z')
if (post) {
  console.log(post.attrs.title)  // value at that timestamp
}

revertTo(db, revision)

ts
static revertTo(
  db: D1Database,
  revision: ModelRevision
): Promise<TModel>
static revertTo(revision: ModelRevision): Promise<TModel>  // auto-resolve db

Restores a model to the state captured in a revision record. Obtain ModelRevision objects by querying the ModelRevision model directly, or via its latestAsOf / listUpTo helpers.

ts
import { ModelRevision } from '@orphnet/d1-eloquent'

// Revision rows are keyed by `model_table` + `model_id` (not `model_type`)
const revisions = await ModelRevision.query()
  .whereEq('model_table', 'posts')
  .whereEq('model_id', postId)
  .orderBy('created_at', 'DESC')
  .get(env.DB)

await Post.revertTo(env.DB, revisions[0])

ModelRevision helpers

ModelRevision.latestAsOf(db, { table, id, asOfIso }) returns the most recent revision at or before asOfIso, and ModelRevision.listUpTo(db, { table, id, asOfIso }) returns all revisions up to that point in ascending order. Both take the options object as the second argument and back the asOf() time-travel path.

dynamic(config)

ts
static dynamic<TAttrs>(config: TDynamicModelConfig<TAttrs>): TModelCtor<BaseModel<TAttrs>>

Creates a fully functional BaseModel subclass at runtime from a configuration object. No pre-defined model file needed. The returned class works identically to a hand-written model class — all features are supported (CRUD, relations, casts, hooks, scopes, soft deletes, revisions, eager loading).

The optional TAttrs generic provides opt-in type safety when you know the schema at compile time.

ts
import { BaseModel } from '@orphnet/d1-eloquent'
import type { TDynamicModelConfig } from '@orphnet/d1-eloquent'

// Untyped — attributes are Record<string, unknown>
const Product = BaseModel.dynamic({
  table: 'products',
  modelName: 'Product',
  softDeletes: true,
  casts: { price: 'real', specs: 'json', is_active: 'boolean' },
  fillable: ['id', 'name', 'price', 'specs', 'is_active'],
})

const product = await Product.find(env.DB, 'some-id')

// Typed — opt-in compile-time safety
interface ProductAttrs {
  id: string
  name: string
  price: number
  specs: Record<string, unknown>
  is_active: boolean
}

const TypedProduct = BaseModel.dynamic<ProductAttrs>({
  table: 'products',
  casts: { price: 'real', specs: 'json', is_active: 'boolean' },
  fillable: ['id', 'name', 'price', 'specs', 'is_active'],
})

Config properties — all optional except table:

PropertyTypeDefaultDescription
tablestringrequiredD1 table name
primaryKeystring'id'Primary key column
modelNamestringHuman-readable name for error messages
timestampsbooleantrueAuto-manage created_at/updated_at
softDeletesbooleanfalseEnable soft-delete
castsRecord<string, CastDefinition>Attribute casting
relationsRecord<string, TRelationDefinition>Declarative relationships
scopesRecord<string, (q) => void>Named query scopes
hooksTHooks<any>Lifecycle hooks
fillablestring[]Mass-assignment whitelist
guardedstring[]Mass-assignment blacklist
connectionD1Database | stringDefault DB connection
accessorsRecord<string, Attribute>Accessor/mutator definitions
appendsstring[]Virtual keys in toObject()
hiddenstring[]Keys excluded from toObject()
revisionsRevisionConfig | falseAudit trail config
revisionRedactstring[]Fields excluded from revisions
revisionOnlystring[] | nullFields included in revisions

The factory automatically validates the config and throws EloquentException if table is missing or empty.


validateDynamicModel(ctor)

ts
function validateDynamicModel(ctor: unknown): asserts ctor is TModelCtor<any>

Validates that a constructor has the required static properties (table, primaryKey) to function as a model. Called automatically by dynamic(). Also useful for validating manually-constructed model classes.

ts
import { validateDynamicModel } from '@orphnet/d1-eloquent'

class ManualModel extends BaseModel {
  static table = 'items'
  static primaryKey = 'id'
}

validateDynamicModel(ManualModel) // passes
validateDynamicModel({})           // throws EloquentException

Instance Methods

set(attrs)

ts
set(attrs: Partial<TAttrs>): this

Merges attrs into the in-memory model state. Returns this for chaining. Changes are not persisted until you call .save().

Values are routed through mutators defined in static accessors. If a mutator returns a plain object, fan-out occurs: the object's keys are spread to their respective columns. See Mutator Fan-Out.

ts
post.set({ title: 'Updated title' }).set({ user_id: newUserId })
await post.save(env.DB)

save(db, opts?)

ts
save(opts?: {                         // auto-resolve db
  revision?: RevisionContext
  cache?: CacheAdapter
}): Promise<void>
save(
  db: D1Database,
  opts?: {
    revision?: RevisionContext
    cache?: CacheAdapter
  }
): Promise<void>

Persists the current in-memory state. Issues an UPDATE for only the changed fields (dirty tracking). If the model has not been persisted yet, issues an INSERT.

ts
post.set({ title: 'New title' })
await post.save(env.DB, {
  revision: { actorId: userId, reason: 'user edit' },
})

In-place mutation of cast values is tracked

Mutating a json, array, or Date cast attribute in place — e.g. post.attrs.tags.push('new') or post.get('metadata').key = 'val' — is now detected on save() and emits an UPDATE. The original-snapshot used for dirty diffing and the revision before value are deep-copied at hydration time, so an in-place edit no longer corrupts the revision before-snapshot.

Replacing the whole value via set() (e.g. post.set('tags', [...post.get('tags'), 'new'])) remains the clearest, most explicit pattern and is still recommended for readability.


delete(db, opts?)

ts
delete(opts?: {                       // auto-resolve db
  revision?: RevisionContext
  cache?: CacheAdapter
}): Promise<void>
delete(
  db: D1Database,
  opts?: {
    revision?: RevisionContext
    cache?: CacheAdapter
  }
): Promise<void>

When softDeletes = false (default): issues DELETE FROM table WHERE id = ?.

When softDeletes = true: sets deleted_at to the current UTC timestamp and issues an UPDATE. The row remains in the database and can be restored.

ts
await post.delete(env.DB, {
  revision: { actorId: userId, reason: 'content removed' },
})

restore(db)

ts
restore(db: D1Database): Promise<void>

Clears deleted_at, making the record visible to normal queries again. Only meaningful when softDeletes = true.

ts
const post = await Post.query().onlyTrashed().whereEq('id', postId).first(env.DB)
if (post) {
  await post.restore(env.DB)
}

get(key)

ts
get<K extends keyof TAttrs>(key: K): TAttrs[K]

Returns the value for key, resolved through the accessor pipeline (cast first, then accessor). If the key has a get-only accessor with no backing column, returns the computed virtual value.

ts
const name = user.get('name')       // accessor-transformed value
const full = user.get('full_name')  // virtual attribute (if defined)

getRaw(key)

ts
getRaw<K extends keyof TAttrs>(key: K): TAttrs[K]

Returns the cast-hydrated value for key, bypassing the accessor pipeline. Useful when you need the raw stored value without accessor transformation.

ts
user.get('email')     // "alice@example.com" (accessor lowercased it)
user.getRaw('email')  // "Alice@Example.com" (cast-hydrated, no accessor)

getOriginal(key)

ts
getOriginal<K extends keyof TAttrs>(key: K): TAttrs[K]

Returns the value for key from the construction-time or last-save snapshot. Useful for comparing current vs. original state.

ts
user.set({ name: 'Bob' })
user.get('name')          // "Bob"
user.getOriginal('name')  // "Alice" (value at load time)

clearAccessorCache()

ts
clearAccessorCache(): void

Clears the per-instance accessor result cache. The cache is automatically invalidated on any write operation (set, fill, setRaw, forceFill), but call this manually if external state that an accessor depends on has changed.


asProxy()

ts
asProxy(): ModelProxy<TAttrs, TVirtuals>

Returns a Proxy wrapper that enables direct property access (proxy.key) routing through the accessor/mutator pipeline. The proxy is lazily created and cached on the instance.

  • proxy.key is equivalent to model.get('key')
  • proxy.key = value is equivalent to model.set('key', value)
  • proxy.$model returns the underlying BaseModel instance
  • JSON.stringify(proxy) matches model.toObject()
  • proxy instanceof Model returns true

See Proxy Access for full details.


fill(values) / forceFill(values)

ts
fill(values: Partial<TAttrs>): this
forceFill(values: Partial<TAttrs>): this

Merges values into the model. Both methods route each key through its mutator (if defined in static accessors).

  • fill() respects static fillable (whitelist) and static guarded (blacklist). Keys not permitted are silently skipped.
  • forceFill() bypasses fillable/guarded restrictions — all keys are accepted.
ts
class User extends BaseModel<UserAttrs> {
  static guarded = ['id', 'role']  // block mass-assignment of id and role
}

user.fill({ id: 'x', name: 'Alice', role: 'admin' })
// Only name is set — id and role are guarded

user.forceFill({ role: 'admin' })
// role is set — forceFill bypasses guarded

toRaw()

ts
toRaw(): Record<string, unknown>

Returns all attributes dehydrated back to DB-safe primitives. Useful for manual SQL queries, serialization, or debugging.

toRaw() never includes virtual or appended keys — it returns only real column data in DB-safe format.

ts
const raw = post.toRaw()
// { id: "...", is_published: 1, metadata: '{"key":"val"}', created_at: "2026-01-15T...", ... }

See also: toObject() returns cast (application) values.


toObject()

ts
toObject(): TAttrs

Returns all attributes with cast values applied. This is the primary way to access model data for API responses or UI rendering.

Virtual attribute keys listed in static appends are included. Keys listed in static hidden are excluded. Hidden takes precedence over appends.

ts
const obj = post.toObject()
// { id: "...", is_published: true, metadata: { key: "val" }, created_at: Date, ... }
// Includes appended virtuals, excludes hidden keys

toJSON() vs toObject() vs toRaw()

Three serialization methods, three jobs. Pick by what you're handing the value to:

MethodReturnsRelations?ValuesReach for it when…
toObject()TAttrs✗ (attributes only)Cast/application values (Date, boolean, parsed JSON)You want a plain attribute object — UI props, a single-record API payload, logging
toJSON()Record<string, unknown>✓ (recursively serialized)Same cast values, relations folded inThe model has eager-loaded relations you want nested in the response
toRaw()Record<string, unknown>DB-safe primitives (0/1, ISO strings, stringified JSON)You're feeding values back into raw SQL, a cache key, or another datastore

For API responses:

ts
// Single record, no relations → toObject()
return c.json(user.toObject())

// Record with eager-loaded relations → toJSON() (relations nested automatically)
const post = await Post.query().with(['author', 'comments']).first()
return c.json(post.toJSON())
// { id, title, author: { ... }, comments: [ ... ] }

// A Collection → map each model (use toObject for flat, toJSON to include relations)
const users = await User.query().get()
return c.json(users.map(u => u.toObject()))

JSON.stringify(model) calls toObject() under the hood (via the proxy), so returning a raw model from c.json() serializes its attributes — but without relations. Call toJSON() explicitly when you need nested relations in the payload.

toRaw() is not for API responses

toRaw() emits 0/1 for booleans and '{"k":"v"}' strings for JSON columns — correct for SQL binding, wrong for clients expecting true/false and real objects. Use toObject() / toJSON() for anything a consumer reads.

Typed Relations

The third optional generic on BaseModel<TAttrs, TVirtuals, TRels> types the .relations property so eager-loaded results are statically known.

ts
interface UserAttrs {
  id: string
  name: string
}

// Declare the relations shape ↓
type UserRels = {
  posts: Post[]
  profile: Profile | null
}

class User extends BaseModel<UserAttrs, {}, UserRels> {
  static table = 'users'
  static relations: Record<string, TRelationDefinition> = {
    posts: { type: 'hasMany', model: () => Post, foreignKey: 'user_id' },
    profile: { type: 'hasOne', model: () => Profile, foreignKey: 'user_id' },
  }
}

const user = await User.query().with(['posts', 'profile']).first(db)
user!.relations.posts     // Post[]  — typed, no `as any`
user!.relations.profile   // Profile | null

When a relation isn't loaded, relations[name] is undefined. Declare relation values as T | null (hasOne / belongsTo / morphTo) or T[] (hasMany / many-to-many) based on the relation's cardinality.

Default: TRels = Record<string, unknown>, so existing models without the third generic continue to behave exactly as before.

TModelRelationsOf<TModel>

Utility type that extracts the relations type from a model class:

ts
import type { TModelRelationsOf } from '@orphnet/d1-eloquent'

type UserRelations = TModelRelationsOf<User>  // { posts: Post[]; profile: Profile | null }

Instance Properties

attrs

ts
get attrs(): TAttrs

Returns the current attribute state of the model as a plain object. Access field values here. Values are cast according to static casts.

ts
console.log(post.attrs.title)
console.log(post.attrs.created_at)  // Date instance (auto-cast from ISO string)

toJSON()

ts
toJSON(): Record<string, unknown>

Serializes the model to a plain object including loaded relations. Relations are recursively serialized via their own toJSON() / toObject() methods.

ts
const user = await User.query().with(['posts']).first(db)
const json = user.toJSON()
// { id: "u1", name: "Alice", posts: [{ id: "p1", title: "Hello", ... }] }

Use toObject() for attributes only (no relations). Use toJSON() for API responses that include eagerly-loaded relations.


ts
related(name: string): TRelationship<any>

Resolve a named relation from static relations metadata for lazy loading.

ts
const posts = await user.related('posts').get(db)
const author = await post.related('author').first(db)

See Relationships API for full details.


attach() / detach() / sync() / toggle()

ts
attach(name: string, ids: string | number | (string | number)[], opts?: { extras?: Record<string, unknown>; db?: D1Database }): Promise<number>
detach(name: string, ids?: string | number | (string | number)[], opts?: { db?: D1Database }): Promise<number>
sync(name: string, ids: (string | number)[], opts?: { extras?: Record<string, unknown>; db?: D1Database }): Promise<TPivotSyncResult>
toggle(name: string, ids: string | number | (string | number)[], opts?: { extras?: Record<string, unknown>; db?: D1Database }): Promise<TPivotSyncResult>

Direct-on-the-model shortcuts for the four pivot-management methods, letting you skip the .related(name) hop on pivot-backed relationships (belongsToMany, morphToMany, morphedByMany). Each is exactly equivalent to model.related(name).<method>(...) and throws if the named relation is not pivot-backed.

ts
// These two lines are equivalent:
await post.attach('tags', [tagId1, tagId2])
await post.related('tags').attach([tagId1, tagId2])

await post.detach('tags', tagId1)          // remove one pivot row
await post.sync('tags', [tagId1, tagId3])  // make pivot rows exactly this set
await post.toggle('tags', tagId2)          // attach if absent, detach if present

See Relationships API for attach / detach / sync / toggle semantics and the TPivotSyncResult shape.


load(...relations)

ts
load(db: D1Database, ...relations: string[]): Promise<this>
load(...relations: string[]): Promise<this>  // auto-resolve

Eager-loads relations onto an existing model instance. Mutates the instance's relations and returns this for chaining.

ts
const user = await User.find(env.DB, userId)
await user.load(env.DB, 'posts', 'profile')
console.log(user.relations.posts) // Post[]

fresh(db?)

ts
fresh(db?: D1Database): Promise<TModel | null>

Re-fetches the model from the database, returning a new instance. Does not mutate the caller. Returns null if the record no longer exists. Compare with refresh() which updates the current instance in place.

ts
const fresh = await user.fresh(env.DB)
// user is unchanged, fresh is a new instance with latest DB state

trashed()

ts
trashed(): boolean

Returns true if the model has been soft-deleted (deleted_at is set). Always returns false when softDeletes is not enabled.

ts
const post = await Post.find(env.DB, postId)
await post.delete(env.DB)
post.trashed() // true

is(other) / isNot(other)

ts
is(other: BaseModel | null | undefined): boolean
isNot(other: BaseModel | null | undefined): boolean

Compare two model instances by constructor and primary key. is() returns true only when both instances are the same model class with the same primary key value.

ts
const a = await User.find(env.DB, userId)
const b = await User.find(env.DB, userId)
a.is(b)    // true  — same class, same PK
a.isNot(b) // false

const post = await Post.find(env.DB, postId)
a.is(post) // false — different class
a.is(null) // false

Convenience Static Methods

createMany(rows, opts?)

ts
static createMany(db?, rows: Partial<TAttrs>[], opts?: {
  cache?: CacheAdapter
  skipRevisions?: boolean
}): Promise<TModel[]>

Bulk-create multiple rows in a single db.batch() round-trip while still running per-row mutators, casts, timestamps, and saving / creating / created / saved hooks. Rows whose creating or saving hook returns false are silently filtered out — they appear in neither the database nor the returned array.

ts
const users = await User.createMany([
  { id: crypto.randomUUID(), name: 'Alice', email: 'a@x.com' },
  { id: crypto.randomUUID(), name: 'Bob',   email: 'b@x.com' },
  { id: crypto.randomUUID(), name: 'Carol', email: 'c@x.com' },
])
// users: TModel[] of proxy-wrapped instances

Limitations vs. looping create():

  • Revisions are not written. If the model has revisions.enabled = true, this method throws unless the caller passes { skipRevisions: true }. Use create() in a loop when you need a revision row per insert.
  • Cache invalidation runs once per row after the batch (best-effort, not rolled back if the batch fails).

For batch insert without hooks/casts (faster, less safe), use Model.query().insertMany(rows) on the QueryBuilder.


firstOrCreate(search, values?)

ts
static firstOrCreate(db?, search, values?): Promise<TModel>

Find the first record matching search conditions, or create a new one with search + values merged.

ts
const user = await User.firstOrCreate(db,
  { email: 'alice@example.com' },
  { id: 'u1', name: 'Alice' },
)

firstOrNew(search, values?)

ts
static firstOrNew(db?, search, values?): Promise<TModel>

Like firstOrCreate, but does not persist the new instance. Returns an unpersisted model that you can modify and save later.

ts
const user = await User.firstOrNew(db, { email: 'new@example.com' }, { id: 'u2', name: 'New' })
user._persisted // false
await user.save(db) // now persisted

updateOrCreate(search, values)

ts
static updateOrCreate(db?, search, values): Promise<TModel>

Find or create, then update with values. Always persists.

ts
const user = await User.updateOrCreate(db,
  { email: 'alice@example.com' },
  { name: 'Alice Updated', last_login: now },
)

Scopes

Define reusable named query constraints via static scopes. Apply them with .scoped() on QueryBuilder.

ts
class Post extends BaseModel<PostAttrs> {
  static table = 'posts'
  static scopes = {
    active: (q) => q.where('status', '=', 'active'),
    recent: (q) => q.orderBy('created_at', 'desc').limit(10),
  }
}

// Apply scopes
const posts = await Post.query().scoped('active', 'recent').get(db)

// Combine with manual conditions
const posts = await Post.query()
  .scoped('active')
  .where('user_id', '=', userId)
  .get(db)

Lifecycle Hooks

Register callbacks that fire during model persistence events via static hooks.

Hook Events

EventWhenCan cancel?
creatingBefore first insertYes (return false)
createdAfter first insertNo
updatingBefore updateYes
updatedAfter updateNo
savingBefore any save (insert or update)Yes
savedAfter any saveNo
deletingBefore deleteYes
deletedAfter deleteNo

Defining Hooks

ts
class Post extends BaseModel<PostAttrs> {
  static table = 'posts'
  static hooks = {
    creating: (model) => {
      // Auto-generate slug
      model.set('slug', slugify(model.get('title')))
    },
    deleting: (model) => {
      // Prevent deletion of published posts
      if (model.get('status') === 'published') return false
    },
    deleted: (model) => {
      console.log(`Deleted post ${model.getKey()}`)
    },
  }
}

Multiple Handlers

Pass an array of handlers per event. They run in order; the first false return cancels.

ts
static hooks = {
  saving: [
    (model) => { /* validate */ },
    (model) => { /* audit log */ },
  ],
}

Mass Assignment Protection

Control which attributes can be set via fill().

static fillable

Whitelist — only these keys are accepted by fill().

ts
class User extends BaseModel<UserAttrs> {
  static fillable = ['name', 'email']
}

user.fill({ id: 'x', name: 'Alice', role: 'admin' })
// Only name and email are set

static guarded

Blacklist — these keys are blocked by fill().

ts
class User extends BaseModel<UserAttrs> {
  static guarded = ['id', 'role']
}

forceFill()

Bypasses both fillable and guarded restrictions. Use for internal/trusted operations.

ts
user.forceFill({ role: 'admin' }) // always works

Exceptions

All ORM exceptions extend EloquentException, which extends Error.

ts
import {
  EloquentException,
  ModelNotFoundException,
  MultipleRecordsFoundException,
} from '@orphnet/d1-eloquent'
ExceptionThrown byProperties
EloquentExceptionBase class for all ORM errorsmessage
ModelNotFoundExceptionfindOrFail(), firstOrFail(), sole() (0 results)model: string, id?: string
MultipleRecordsFoundExceptionsole() (>1 results)model: string, count: number
ts
try {
  await User.findOrFail(env.DB, 'nonexistent')
} catch (e) {
  if (e instanceof ModelNotFoundException) {
    console.log(e.model) // "users"
    console.log(e.id)    // "nonexistent"
  }
}

try {
  await User.query().whereEq('role', 'admin').sole(env.DB)
} catch (e) {
  if (e instanceof MultipleRecordsFoundException) {
    console.log(e.count) // number of matching records
  }
}

Released under the MIT License.