Skip to main content
zerotal

ORM

Zerotal's ORM is an Active Record layer built on top of Bun's native SQL client. Each model class maps to a database table, columns are declared with decorators, and queries read fluently in TypeScript. The same model code runs on SQLite, PostgreSQL, and MySQL.

  • Casts & Mutators — column types, custom casts, reactive JSON, the static casts map.
  • Queries — the query builder, instance methods, scopes, and pagination.
  • RelationshipsbelongsTo/hasMany/etc., eager loading, and pivot operations.
  • Serializationhidden/visible/appends and JSON output.
  • Lifecycle & Events — hooks, observers, state machines, dispatchesEvents, pruning.
  • Migrations — evolving your tables as models change.
  • Seeding — populating tables with factory and seeder data.

Note — Working with raw SQL — transactions, the DB query builder, replicas, multiple connections, and the events the database emits — lives in Database. The ORM is for models; the raw layer is for everything beneath them. Date/time values use Carbon.

Getting Started

# in your project root
bun add @zerotal/orm

Register the provider

Add DatabaseProvider to the providers array in bootstrap/providers.ts:

// bootstrap/providers.ts
import { DatabaseProvider } from "@zerotal/orm";

const providers = [
  // …your other providers
  DatabaseProvider,
];

export default providers;

Registering the provider switches on the following (in lifecycle order):

  • onRegister — binds the db connection as a lazy singleton, registers model/observer auto-discovery and implicit route-model binding, and wires the connection resolver.
  • onBooting — opens the connection, detects the SQL dialect, bridges dispatchesEvents to the app event bus, and registers the validator's unique()/exists() rules.
  • onBooted — enables N+1 query detection outside production and registers the migrate, make:model, db:seed, and related commands.
  • onStopping — closes the connection so nothing leaks between boots or test suites.

Configuration

Create config/database.ts. Use the DatabaseConfig() helper so every field stays type-checked while literal values stay inferred:

// config/database.ts
import { DatabaseConfig } from "@zerotal/orm";
import { env } from "zerotal";

export default DatabaseConfig({
  // One of 'sqlite' | 'postgres' | 'mysql', written literally: the field is that
  // union, and `env()` returns a plain string. Scaffolding writes your choice here.
  driver: "sqlite",
  url: env("DATABASE_URL", "./database/db.sqlite"),

  // PostgreSQL:  'postgres://user:pass@localhost:5432/mydb'
  // MySQL:       'mysql://user:pass@localhost:3306/mydb'

  replicas: [], // optional read-replica URLs; reads round-robin, writes hit primary

  pool: {
    max: env("DB_POOL_MAX", 10), // max connections (postgres/mysql)
    idleTimeout: env("DB_POOL_IDLE_TIMEOUT", 30), // seconds before idle close
  },

  sqlite: {
    path: env("DB_SQLITE_PATH", "./database/db.sqlite"), // ':memory:' for in-memory
  },
});
FieldRequiredDefaultDescription
driveryes"sqlite"Database driver: "sqlite", "postgres", or "mysql".
urlyes"./database/db.sqlite"Connection URL (or SQLite file path / :memory:).
replicasno[]Read-replica URLs. Reads round-robin to replicas; writes and transactions hit primary.
pool.maxno10Maximum pool connections (PostgreSQL / MySQL only).
pool.idleTimeoutno30Seconds an idle connection is kept before closing.
sqlite.pathyes"./database/db.sqlite"SQLite file path. Use ":memory:" for an in-memory database.
synchronizenofalseAuto-sync the schema to your models at boot. Hard-off in production. See note below.

Danger — For SQLite, do not use a sqlite:// protocol prefix in url. Bun's native SQLite driver expects a raw file path or :memory:.

Warningsynchronize: true performs additive schema sync (creates missing tables and columns) at boot and is forced off in production. Set { enabled: true, disruptive: true } to also DROP columns no model declares anymore — this destroys their data, so keep it local/test only.

Defining models

@table decorator

Every model is configured with the @table() decorator. It accepts a fluent chain or an options object — both styles are equivalent:

// app/models/Post.ts
import { Model, column, table } from "@zerotal/orm";

// Fluent chain (recommended for readability)
@(table("posts").withTimestamps())
export class Post extends Model {
  @column("string") title!: string;
  @column("text") body!: string;
  @column("integer") views!: number;
}

// Options object — same result
@table("posts", { timestamps: true })
export class Post extends Model {
  /* … */
}

// Override the primary key
@(table("users").primaryKey("user_id"))
export class User extends Model {
  /* … */
}

Available chain methods and options:

Chain methodOption (@table(name, {…}))Effect
.withTimestamps()timestamps: trueEnables created_at / updated_at (default on).
.withoutTimestamps()timestamps: falseDisables automatic timestamp management.
.primaryKey("col")primaryKey: "col"Changes the primary key column name (default: "id").

Chain methods need the outer parentheses. Decorator syntax allows a call at the end of the chain, not in the middle of it, so @table("x").withoutTimestamps() is a parse errorExpected "class" but found ".". Write @(table("x").withoutTimestamps()), or use the options object, which needs no parentheses. Plain @table("x") is fine as-is.

Timestamps are on by default, so @table("ledger") alone still writes created_at and updated_at. For an append-only table whose migration creates neither column, say so — otherwise the first save fails with table ledger has no column named updated_at:

@(table("ledger").withoutTimestamps())
export class LedgerEntry extends Model {
  /* … */
}

NoteAuto-discovery: Models under app/models/ don't need @table — they're auto-registered at boot with a conventional table name (pluralize(snake(ClassName)), e.g. Postposts). Use @table only to override the name or options.

Packages and tests: Models defined inside packages or inline in test files always need @table (or registerModel(Class)). @table is the definition-time anchor that registers the queued @column/relation fields — without it, those fields won't register.

Subclasses that add columns also need their own @table, using the same table name for single-table inheritance:

// app/models/Admin.ts
@table("users")
class Admin extends User {
  @column("integer") level!: number; // ← won't register without @table on Admin
}

Tip — Soft deletes are not a @table option. Opt in per model with the SoftDeletes mixin: class Post extends Model.using(SoftDeletes) {}. It adds deletedAt, restore(), forceDelete(), trashed(), and the withTrashed() / onlyTrashed() scopes. See Lifecycle & Events.

@column decorator

Declare typed columns. Accepts a shorthand cast string or a full options object:

// in a model class body
import { column } from "@zerotal/orm";

// Shorthand (most common)
@column("string")   name!:      string;
@column("integer")  views!:     number;
@column("boolean")  active!:    boolean;
@column("datetime") createdAt!: Carbon;
@column("date")     birthday?:  Date;   // native Date — not Carbon; see below
@column("json")     meta!:      Record<string, unknown>;
@column("array")    tags!:      string[];
@column("float")    score!:     number;
@column("text")     bio?:       string;
@column("encrypted") idNumber?: string; // ciphertext at rest, plaintext here

// Shorthand + options — the shorthand keeps its type and cast
@column("string", { nullable: true }) nickname?: string | null;
@column("integer", { nullable: true, default: 0 }) retries?: number | null;

// Full options object
@column({ type: "datetime", cast: "datetime" }) publishedAt?: Carbon;
@column({ type: "number", cast: "decimal:2" }) price!: string; // toFixed() — a string

Shorthands map to: string, text, integer, number, float, boolean, datetime, date, json, array, encrypted, encrypted:json. See Casts & Mutators for the full cast reference.

A shorthand is not the same as type. type is only the storage type — string, text, number, boolean, datetime, json — so { type: "integer" } and { type: "encrypted" } are both errors. The shorthands that look like types (integer, float, encrypted) are type-and-cast pairs: @column("integer") is { type: "number", cast: "integer" }, and @column("encrypted") is { type: "text", cast: "encrypted" }.

string is a bounded VARCHAR and text is the unbounded TEXT type — a distinction that matters on Postgres and MySQL, where a long body in a VARCHAR(255) is an error rather than a slow column.

Two casts surface as a type you might not expect, so declare the property to match what you will actually hold: date hydrates a native Date (only datetime gives you a Carbon), and decimal:N runs .toFixed(N) both ways, so it is a string. TypeScript cannot catch either — the decorator does not constrain the property type — so an annotation that disagrees compiles fine and fails at the first .diffForHumans() or arithmetic.

Indexes and uniqueness

Declare constraints on the column and schema generation emits them, so migrate:generate produces a schema with the guarantees your application depends on rather than a bare set of columns:

@column({ unique: true }) idempotencyKey!: string;   // unique index
@column({ index: true })  status!: string;           // plain index

Generated migrations also add an index to any column whose name ends in _id (customerIdcustomer_id), since an unindexed foreign key is a table scan on every join. The reference itself can't always be inferred; the index can.

Composing model mixins

Reusable model behaviour ships as mixins — soft deletes, state machines, the auth contract, roles, permissions, notifications, tenancy, auditing. A model opts into the ones it wants with the Model.using(...) static, so a model that does not use a feature does not carry its API:

import { Model, SoftDeletes } from "@zerotal/orm";
import { Authenticatable, Roles, Permissions } from "@zerotal/auth";

@table("users")
export class User extends Model.using(Authenticatable, Permissions, Roles) {
  @column() email!: string;
}

@table("posts")
export class Post extends Model.using(SoftDeletes) {
  @column() title!: string;
}

Mixins fold left to right, and the composed class keeps the full Active Record static surface — User.query(), find(), create(), scopes — plus every mixin's instance and static members, fully typed. Prefer this over hand-nesting (Roles(Permissions(AuthUser))), which reads inside-out and repeats the base.

The mixins the framework ships:

MixinPackageAdds
SoftDeletes@zerotal/ormdeletedAt, restore(), withTrashed(), onlyTrashed()
State@zerotal/ormtransitionTo(), guarded state machines
Authenticatable@zerotal/auththe auth contract Auth.attempt() resolves against
Roles@zerotal/authassignRole(), hasRole()
Permissions@zerotal/authgivePermissionTo(), can()
EmailVerification@zerotal/authverification links and state
PasswordReset@zerotal/authreset tokens
Notifiable@zerotal/notificationsnotify() and the notification channels
Tenantable@zerotal/tenancyautomatic per-tenant scoping
Auditable@zerotal/auditchange history
Media@zerotal/mediafile attachments, collections, image conversions

Composing onto a shared base

using composes onto whatever class you call it on, not onto Model specifically, so an app-level base model can carry its own configuration and still take mixins:

class AppModel extends Model {
  static override primaryKey = "uuid";
}

@table("invoices")
export class Invoice extends AppModel.using(SoftDeletes) {} // still uses "uuid"

The composed class carries using itself, so composition can also be chained — Model.using(A, B).using(C).

Writing your own

A mixin is a function taking a base constructor and returning a class that extends it:

import { Model, registerColumn, type Constructor } from "@zerotal/orm";

export function Sluggable<T extends Constructor>(Base: T) {
  return class extends Base {
    slug = "";

    setSlug(from: string): this {
      this.slug = from.toLowerCase().replace(/\s+/g, "-");
      return this;
    }
  };
}

export class Article extends Model.using(Sluggable) {}

Note — a mixin that needs to declare a real database column must call registerColumn() imperatively. The @column decorator cannot run inside a returned class expression.

Mass assignment

Models guard every attribute by default. A model that declares neither fillable nor guarded rejects every attribute passed to create() / fill(), throwing MassAssignmentError. This means a stray key in a request body — a role, an is_admin, an id — can never reach the database just because it was in ctx.body(). To allow columns through, you opt in explicitly.

fillable and guarded

fillable is an allowlist; guarded is a denylist. Use one or the other, not both. Any attribute not permitted by the active list throws MassAssignmentError (it is not silently dropped), so a mistake surfaces loudly instead of quietly failing:

// app/models/Post.ts
@table("posts")
export class Post extends Model {
  // Only these fields are accepted by create() and fill()
  static fillable = ["title", "body", "status"];

  // OR — allow everything except these fields
  static guarded = ["id", "userId"];

  @column("string") title!: string;
  @column("text") body!: string;
  @column("string") status!: string;
}

Use the Columns<T> utility for compile-time safety against typos:

// app/models/Post.ts
import type { Columns } from "@zerotal/orm";

static fillable: Columns<Post>[] = ["title", "body", "status"];
// TypeScript error if you list a column name that doesn't exist on Post

Declare fillable as a literal tuple (as const) and create() narrows its payload to exactly those columns:

@table("customers")
export class Customer extends Model {
  static fillable = ["name", "email"] as const;

  @column() name!: string;
  @column() email!: string;
  // A compliance flag that must never come from a request body
  @column({ type: "boolean", cast: "boolean", default: false }) legalHold!: boolean;
}

await Customer.create({ name: "Ada", email: "ada@example.com" }); // ✓ legalHold not required
await Customer.create({ name: "Ada", email: "…", legalHold: true }); // ✗ compile error

Without the as const, the payload is the full column set, so a required non-fillable column is demanded by the type and rejected by the runtime — a pair of rules that cannot both be satisfied. The narrowing makes the type agree with the guard, and moves the mistake from a runtime MassAssignmentError to a compile error.

Trusted writes — forceFill, forceCreate, unguard

For data you construct yourself (seeders, factories, framework-internal writes) the guard is just friction. Bypass it deliberately:

// Per call — skip the guard for one write:
role.forceFill({ name, guard });
await Role.forceCreate({ name, guard });

// A trusted block — guard disabled inside, restored afterwards (even on throw):
await Model.withoutGuard(() => seeder.run());

// Process-wide (e.g. a seeder entrypoint) — pair with reguard():
Model.unguard();
// … bulk trusted work …
Model.reguard();

// Or opt a single model out entirely (its writes never come from user input):
class AuditLog extends Model {
  static override unguarded = true;
}

An explicit fillable / guarded list is always honoured, even under a global unguard() — so a model that lists its fillable columns stays protected regardless.

Tests: test fixtures are trusted code that create() models freely, so the framework's test suites run with Model.unguard() enabled via a preload (scripts/test-preload.ts). If your app's tests build models directly rather than through factories, do the same, or declare fillable on the models.

fill instance method

// in a controller
post.fill({ title: "New title", body: "Updated body" }); // throws if a key isn't fillable
await post.save();

// Typical controller pattern — ctx.body() is already validated and typed:
post.fill(ctx.body<UpdatePayload<Post>>());
await post.save();

Password hashing

Fields listed in hashable are automatically hashed with Bun.password.hash() (bcrypt) before every INSERT, and on UPDATE only when the value has changed since the last load:

// app/models/User.ts
@table("users")
export class User extends Model {
  static hashable = ["password"];

  @column("string") name!: string;
  @column("string") email!: string;
  @column("string") password!: string;
}

// No manual hashing needed — the ORM handles it transparently:
const user = await User.create({ name: "Alice", email: "alice@example.com", password: "secret" });

// Verify later:
const ok = await Bun.password.verify(candidate, user.password);

Bridging model events to the app event bus

dispatchesEvents connects ORM lifecycle hooks to the application event bus without wiring every observer manually. Declare a map from lifecycle event names to event classes:

// app/events/UserCreated.ts
export class UserCreated {
  constructor(public user: User) {}
}
// app/models/User.ts
@table("users")
export class User extends Model {
  static dispatchesEvents = {
    created: UserCreated,
    deleted: UserDeleted,
    updating: UserUpdating,
  };
}

Valid event keys: creating, created, updating, updated, saving, saved, deleting, deleted, retrieved.

Each event class is constructed with the model instance as its first argument and emitted on the container's event bus (a no-op when no bus is bound, so it's safe in standalone ORM use). Subscribe anywhere:

// app/listeners/sendWelcome.ts
import { Events } from "zerotal";
import { UserCreated } from "#app/events/UserCreated.ts";

Events.on(UserCreated, async ({ user }) => {
  await Mail.send(new WelcomeMail(user.email));
});

Tip — Use dispatchesEvents when event consumers live in separate parts of your application and shouldn't be coupled to the model file. For logic that lives close to the model, reach for an observer instead — see Lifecycle & Events.

Generating models

# in your project root
bun zt make:model Post
bun zt make:model Post --migration   # also create a migration
bun zt make:model Post -m            # shorthand

A generated model skeleton (app/models/Post.ts):

// app/models/Post.ts
import { Model, column, table } from "@zerotal/orm";

@(table("posts").withTimestamps())
export class Post extends Model {
  // Models guard every attribute by default — list the mass-assignable columns.
  static fillable: string[] = ["name"];

  @column() name!: string;
}

Full model example

// app/models/Post.ts
import { Model, column, table, hasMany, belongsTo } from "@zerotal/orm";
import type { Columns } from "@zerotal/orm";
import { Carbon } from "zerotal/carbon";
import type { Comment } from "./Comment.ts";
import type { User } from "./User.ts";

@(table("posts").withTimestamps())
export class Post extends Model {
  // Mass assignment
  static fillable: Columns<Post>[] = ["title", "body", "status", "userId"];

  // Serialization
  static appends = ["excerpt"];

  // Columns
  @column("string") title!: string;
  @column("text") body!: string;
  @column("string") status!: string;
  @column("integer") userId!: number;
  @column("integer") views!: number;
  @column("datetime") publishedAt?: Carbon;
  @column("json") meta!: Record<string, unknown>;

  // Relationships
  @belongsTo(() => User, { foreignKey: "userId" })
  author!: User;

  @hasMany(() => Comment, { foreignKey: "postId" })
  comments!: Comment[];

  // Computed accessor
  get excerpt(): string {
    return this.body.slice(0, 160) + "…";
  }
}

Testing

Set your suite up once as described in Testing, and see Database Testing for rollback and the assertDatabase* family. What follows is what's specific to models.

Factories silence observers and hooks by default. This is the single trap worth knowing: UserFactory.create() writes the row without firing creating, created, or any registered observer, so seeders don't spray logs, mail, and jobs. A test asserting on a side-effect of creation therefore sees nothing — and reads as a bug in your observer rather than in the test.

// tests/models/User.test.ts
import { test } from "bun:test";
import { QueueFake } from "@zerotal/queue";
import { UserFactory } from "../../database/factories/UserFactory.ts";
import { WelcomeEmailJob } from "../../app/jobs/WelcomeEmailJob.ts";

test("creating a user queues the welcome email", async () => {
  const queue = QueueFake.install();

  // Without dispatchEvents() the observer never runs and this assertion fails.
  await UserFactory.dispatchEvents().create();

  queue.assertDispatched(WelcomeEmailJob);
});

Scopes, casts, and accessors need no HTTP. They are model behaviour, so test them against the model directly — it is faster and the failure points at the right line:

// tests/models/Post.test.ts
import { test, expect } from "bun:test";
import { PostFactory } from "../../database/factories/PostFactory.ts";
import { Post } from "../../app/models/Post.ts";

test("the published scope excludes drafts", async () => {
  await PostFactory.create({ status: "published" });
  await PostFactory.create({ status: "draft" });

  const rows = await Post.query().published().get();

  expect(rows).toHaveLength(1);
});

Soft deletes hide rows from the default query, which makes "did it delete?" ambiguous. Assert on both sides — gone from the normal query, present with withTrashed():

// tests/models/Post.test.ts
await post.delete();

expect(await Post.find(post.id)).toBeNull();
expect(await Post.query().withTrashed().where("id", post.id).first()).not.toBeNull();

assertDatabaseMissing("posts", { id }) would fail here, because the row is still on disk with a deleted_at stamp. Use onlyTrashed() or assert on the column instead.

References

Static configuration properties read from the model class:

PropertyTypeDescription
tablestringTable name. Set by @table or inferred from the class name.
primaryKeystringPrimary key column. Default "id".
timestampsbooleanWhether created_at / updated_at are managed. Default true.
fillablestring[]Mass-assignment allowlist. Not permitted → MassAssignmentError.
guardedstring[]Mass-assignment denylist (use instead of fillable).
unguardedbooleanDisable mass-assignment guarding for this model. Default false (guarded).
hashablestring[]Fields auto-hashed with Bun.password.hash() on change.
hiddenstring[]Fields omitted from JSON output. See Serialization.
visiblestring[]Allowlist of fields included in JSON output.
appendsstring[]Computed accessors appended to JSON output.
dispatchesEventsRecord<string, new (m) => object>Maps lifecycle event keys to event classes emitted on the event bus.
implicitBindingbooleanSet false to opt the model out of route-model binding.

Type helpers exported from @zerotal/orm:

HelperDescription
Columns<T>Union of a model's column names, for typo-safe fillable/hidden.
InsertPayload<T>The shape accepted by create().
UpdatePayload<T>The shape accepted by fill() / update().
DatabaseConfigShapeThe config/database.ts configuration type.

Commands

@zerotal/orm ships the migration, model, and seeding commands. Every one runs through bun zt:

CommandWhat it does
bun zt migrateRun all pending database migrations (alias: db:migrate)
bun zt migrate:rollbackRoll back the most recent migration batch
bun zt migrate:freshRoll back every migration, then re-run them from scratch
bun zt migrate:statusShow the status of each migration
bun zt migrate:generateAuto-generate a migration from model schema changes
bun zt make:model Post --migrationCreate a model class, optionally with a migration
bun zt make:migration create_posts_tableCreate a new migration file
bun zt make:factory PostFactoryCreate a new model factory
bun zt make:seeder PostSeederCreate a new database seeder class
bun zt db:seedRun database seeders from database/seeders/

Next steps