@roostjs/orm
Laravel-inspired ORM built on Drizzle for D1 databases. Model classes, query builder, relationships, lifecycle hooks, factories.
Installation
bun add @roostjs/ormModel 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. PostComment → post_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 | nullLifecycle 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)
| Parameter | Description |
|---|---|
pattern | Binding name template. Default: 'DB_TENANT_{SLUG}'. {SLUG} is replaced with the uppercased, hyphen-to-underscore org slug. |
resolveBinding | Function 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)
| Parameter | Description |
|---|---|
resolver | Any object implementing resolve(request: Request): { slug: string } | null. OrgResolver from @roostjs/auth satisfies this interface. |
orgLookup | Async function that returns { id: string } for a known slug, or null if the org does not exist. |
ctx | The 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():
| Key | Type | Default | Description |
|---|---|---|---|
database.tenantStrategy | 'row' | 'database' | 'row' | 'row' uses tenantColumn filtering on a shared DB. 'database' resolves a per-tenant D1 binding via TenantDatabaseResolver. |
database.useSession | boolean | false | Wrap the D1 binding in D1SessionHandle for read-your-writes consistency. |
database.d1Binding | string | 'DB' | The shared D1 binding name. |
database.tenantBindingPattern | string | '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.