@roostjs/orm

Laravel-inspired ORM built on Drizzle for D1 databases. Model classes, query builder, relationships, lifecycle hooks, factories.

Installation

bun add @roostjs/orm

Model API

Extend Model to define a database table mapping. All queries operate on the subclass statically. Results are instances of the subclass with attributes accessible via the attributes property.

Static Configuration Properties

static tableName: string | null

The database table name. Defaults to null, which causes the ORM to derive the table name from the class name via toTableName (e.g. PostCommentpost_comments). Set explicitly to override.

static primaryKey: string

The primary key column name. Defaults to 'id'.

static timestamps: boolean

When true, automatically writes created_at on insert and updated_at on update. Defaults to true.

static softDeletes: boolean

When true, delete() sets deleted_at instead of removing the row. Defaults to false.

Static Query Methods

static async find(id: unknown): Promise<T | null>

Find a record by primary key. Returns null if not found.

static async findOrFail(id: unknown): Promise<T>

Find a record by primary key. Throws ModelNotFoundError if not found.

static async all(): Promise<T[]>

Retrieve all records from the table.

static async create(attributes: Record<string, unknown>): Promise<T>

Insert a new record and return the created model instance. Fires creating and created hooks.

static where(column: string, value: unknown): QueryBuilder<T>

static where(column: string, op: Operator, value: unknown): QueryBuilder<T>

Begin a query with a WHERE constraint. Returns a QueryBuilder.

static whereIn(column: string, values: unknown[]): QueryBuilder<T>

Begin a query with a WHERE IN constraint. Returns a QueryBuilder.

static on(event: HookName, callback: HookFn): void

Register a lifecycle hook callback. Return false from a pre-event hook to abort the operation.

Instance Methods

async save(): Promise<this>

Persist changes to this.attributes. Fires updating and updated hooks. Returns the instance.

async delete(): Promise<void>

Delete the record (or soft-delete if softDeletes = true). Fires deleting and deleted hooks.

attributes: Record<string, unknown>

The raw column values for this record. Mutate these before calling save().

QueryBuilder API

All static model query methods return a QueryBuilder that is chainable. The query is not executed until a terminal method is called.

where(column: string, value: unknown): this

where(column: string, op: Operator, value: unknown): this

Add an AND WHERE clause. Supported operators: =, !=, >, <, >=, <=, like.

orWhere(column: string, value: unknown): this

Add an OR WHERE clause (equality only).

whereIn(column: string, values: unknown[]): this

Add a WHERE IN clause.

whereNull(column: string): this

Add a WHERE IS NULL clause.

whereNotNull(column: string): this

Add a WHERE IS NOT NULL clause.

orderBy(column: string, direction?: 'asc' | 'desc'): this

Add an ORDER BY clause. Direction defaults to 'asc'. Chainable for multiple sort columns.

limit(n: number): this

Limit the number of results.

offset(n: number): this

Skip the first n results.

with(...relations: string[]): this

Eager-load named relations when results are fetched.

first(): Promise<T | null>

Execute the query and return the first result, or null.

firstOrFail(): Promise<T>

Execute the query and return the first result. Throws ModelNotFoundError if no result.

all(): Promise<T[]>

Execute the query and return all matching records.

count(): Promise<number>

Execute a COUNT query and return the number of matching records.

paginate(page: number, perPage: number): Promise<PaginationResult<T>>

Execute the query with pagination. Returns a PaginationResult with data, total, perPage, currentPage, and lastPage.

Relationships

Define relationships by instantiating the relation classes. Call load(parent) on an instance to fetch related records, or loadMany(parents) to batch-load for a collection.

new HasOneRelation(RelatedModel, foreignKey, localKey?)

One-to-one ownership: this model has one related record. localKey defaults to 'id'. load() returns Promise<RelatedModel | null>.

new HasManyRelation(RelatedModel, foreignKey, localKey?)

One-to-many ownership. localKey defaults to 'id'. load() returns Promise<RelatedModel[]>.

new BelongsToRelation(RelatedModel, foreignKey, localKey?)

Inverse of HasOneRelation or HasManyRelation. localKey defaults to 'id'. load() returns Promise<RelatedModel | null>.

import { Model, HasManyRelation, BelongsToRelation } from '@roostjs/orm';
import { Post } from './Post';

export class User extends Model {
  static tableName = 'users';
  static posts = new HasManyRelation(Post, 'author_id', 'id');
}

export class Post extends Model {
  static tableName = 'posts';
  static user = new BelongsToRelation(User, 'author_id', 'id');
}

// Usage
const post = await Post.findOrFail(1);
const author = await Post.user.load(post); // User | null

Lifecycle Hooks

Hooks fire in the order they are registered. Pre-event hooks abort the operation if any returns false.

// Pre-event hooks (can abort by returning false)
Model.on('creating', (instance) => { ... });
Model.on('updating', (instance) => { ... });
Model.on('deleting', (instance) => { ... });

// Post-event hooks
Model.on('created', (instance) => { ... });
Model.on('updated', (instance) => { ... });
Model.on('deleted', (instance) => { ... });

Factory API

Factory generates model instances with fake data for seeding and testing. Extend it and implement the definition() method.

abstract definition(): Record<string, unknown>

Return the default attribute map. Called once per generated instance.

count(n: number): this

Set the number of instances to generate. Chainable.

state(modifier: (attrs) => attrs): this

Apply an attribute override to each generated instance. Chainable for multiple states.

async make(): Promise<T[]>

Build unsaved model instances without persisting.

async makeOne(): Promise<T>

Build a single unsaved model instance.

async create(): Promise<T[]>

Create and persist model instances.

async createOne(): Promise<T>

Create and persist a single model instance.

import { Factory } from '@roostjs/orm';
import { Post } from '../../src/models/Post';

class PostFactory extends Factory<typeof Post> {
  definition() {
    return {
      title: 'Test Post ' + Math.random().toString(36).slice(2),
      slug: 'test-post-' + Math.random().toString(36).slice(2),
      body: 'Lorem ipsum dolor sit amet.',
      author_id: 1,
      status: 'published',
    };
  }
}

const factory = new PostFactory(Post);

// Create 3 draft posts
const drafts = await factory.count(3).state((a) => ({ ...a, status: 'draft' })).create();

// Create one
const post = await factory.createOne();

Types

type Operator = '=' | '!=' | '>' | '<' | '>=' | '<=' | 'like';
type HookName = 'creating' | 'created' | 'updating' | 'updated' | 'deleting' | 'deleted';
type HookFn = (model: any) => boolean | void | Promise<boolean | void>;
type ModelAttributes = Record<string, unknown>;

interface PaginationResult<T> {
  data: T[];
  total: number;
  perPage: number;
  currentPage: number;
  lastPage: number;
}

Tenant Scoping

Model.tenantColumn

static tenantColumn: string | null = null;

Set tenantColumn to the column name that stores the tenant identifier (e.g. 'org_id'). When set and a TenantContext is active, all queries — find, findOrFail, all, create, save, delete, and every QueryBuilder chain — automatically include WHERE {tenantColumn} = {orgId} using the current context value. The tenant condition is always prepended before any caller-supplied where clauses.

On create(), the tenant column value from the context overwrites any caller-supplied value for that column, ensuring rows can never be created outside the active tenant.

import { Model } from '@roostjs/orm';

export class Post extends Model {
  static tableName = 'posts';
  static tenantColumn = 'org_id';
}

Model.withoutTenantScope()

static async withoutTenantScope<T>(fn: () => Promise<T>): Promise<T>

Execute fn with the tenant scope bypassed for this model class. The context is restored after fn resolves or rejects. Use for administrative queries that must cross tenant boundaries.

const allPosts = await Post.withoutTenantScope(() => Post.all());

TenantContext

Request-scoped object that holds the active tenant identity. Injected into all model classes by OrmServiceProvider at boot. Resolve from the DI container when you need to read or set the tenant outside a model query.

set(data: TenantContextData): void

Set the active tenant. Called by TenantScopeMiddleware after resolving the org.

get(): TenantContextData | null

Return the current tenant data, or null if not set.

isBypassed(): boolean

Return true if withoutTenantScope() is currently active.

bypass(): void

Disable tenant filtering. Called internally by withoutTenantScope().

restore(): void

Re-enable tenant filtering. Called internally by withoutTenantScope().

interface TenantContextData {
  orgId: string;
  orgSlug: string;
}

TenantDatabaseResolver

Resolves a per-tenant D1 binding by mapping an org slug to a binding name using a configurable pattern.

constructor(pattern: string, resolveBinding: (name: string) => D1Database | null)

ParameterDescription
patternBinding name template. Default: 'DB_TENANT_{SLUG}'. {SLUG} is replaced with the uppercased, hyphen-to-underscore org slug.
resolveBindingFunction that returns the D1Database for a given binding name, or null if not found.

resolve(slug: string): D1Database | null

Converts slug to a binding name using pattern and calls resolveBinding. Returns null if no matching binding exists.

Example: slug acme-corp with pattern DB_TENANT_{SLUG} resolves to binding name DB_TENANT_ACME_CORP.

D1SessionHandle

Wraps a D1Database binding to enforce read-your-writes consistency using the D1 Sessions API.

constructor(db: D1Database)

sessionAwareRaw(): D1Database

Returns a session-aware D1Database handle. If markWritten() has been called, the returned handle calls db.withSession(token) to route reads to a replica that has seen the most recent write. Falls back to the plain binding if withSession() is unavailable (e.g. local dev / Miniflare).

markWritten(token?: string): void

Record that a write has occurred. Pass a session token string to use a specific session; omit to use the unconditional first-session sentinel. Call this immediately after any INSERT, UPDATE, or DELETE that must be visible to subsequent reads in the same request.

TenantScopeMiddleware

Sets the active TenantContext from the incoming request using an OrgResolvable and an org lookup function. Place this middleware before any route handler that queries tenant-scoped models.

constructor(resolver: OrgResolvable, orgLookup: (slug: string) => Promise<{ id: string } | null>, ctx: TenantContext)

ParameterDescription
resolverAny object implementing resolve(request: Request): { slug: string } | null. OrgResolver from @roostjs/auth satisfies this interface.
orgLookupAsync function that returns { id: string } for a known slug, or null if the org does not exist.
ctxThe TenantContext instance to populate. Resolve from the DI container.

async handle(request: Request, next: () => Promise<Response>): Promise<Response>

Resolves the org slug from the request, looks up the org ID, and calls ctx.set({ orgId, orgSlug }) before calling next(). Throws TenantNotResolvedError if the slug is found but the org lookup returns null.

interface OrgResolvable {
  resolve(request: Request): { slug: string } | null;
}

OrmServiceProvider — Tenant Strategy Config

OrmServiceProvider reads the following config keys during boot():

KeyTypeDefaultDescription
database.tenantStrategy'row' | 'database''row''row' uses tenantColumn filtering on a shared DB. 'database' resolves a per-tenant D1 binding via TenantDatabaseResolver.
database.useSessionbooleanfalseWrap the D1 binding in D1SessionHandle for read-your-writes consistency.
database.d1Bindingstring'DB'The shared D1 binding name.
database.tenantBindingPatternstring'DB_TENANT_{SLUG}'Binding name pattern for 'database' strategy.
export default {
  tenantStrategy: 'row',      // or 'database' for DB-per-tenant
  useSession: true,           // enable read-your-writes
  d1Binding: 'DB',
  tenantBindingPattern: 'DB_TENANT_{SLUG}',
};

Errors

OrmNotBootedError

Thrown when a query is executed before the ORM is booted (i.e. before app.boot() is called).

ModelNotFoundError

Thrown by findOrFail() and firstOrFail() when no record matches.

InvalidRelationError

Thrown when a relation name that is not defined on the model is accessed.