Model is a tiny active-record layer over the query builder.
Subclass it, point it at a table, and you get find / all / create /
save / delete — no ORM to configure. It runs through whatever
connection you registered, so it works on Node and the edge.
Define a model
import { Model } from "@shaferllc/keel/core";
export class User extends Model {
static table = "users";
static primaryKey = "id"; // default
declare id: number;
declare email: string;
declare name: string;
}
Use declare for columns — it types the properties without emitting fields that
would shadow the row values the model is hydrated with.
Reading
await User.all(); // User[]
await User.find(1); // User | null
await User.findOrFail(1); // User (throws NotFoundException if missing)
await User.first(); // User | null
await User.where("active", true); // User[]
For anything richer, User.query() returns the underlying query builder:
const rows = await User.query().where("age", ">", 18).orderBy("name").limit(10).get();
Writing
// create
const user = await User.create({ email: "a@b.com", name: "Ada" });
// update — change attributes, then save
user.name = "Grace";
await user.save();
// new instance — save() inserts and back-fills the primary key
const draft = new User({ email: "new@x.com" });
await draft.save();
draft.id; // now set
// delete
await user.delete();
save() inserts when there's no primary key and updates when there is — one
method for both. update(attrs) is fill + save, and refresh() reloads a
model's columns from the database:
await user.update({ name: "Grace" }); // mass-assign + save
await user.refresh(); // re-read the row
Find-or-create
// Return the first matching row, or create it from { ...match, ...values }.
const tag = await Tag.firstOrCreate({ slug: "keel" }, { name: "Keel" });
// Update the match if it exists, otherwise create it.
const sub = await Subscription.updateOrCreate({ user_id: 1 }, { plan: "pro" });
Timestamps
Set static timestamps = true and Keel manages created_at / updated_at — both
on insert, just updated_at on update:
class Post extends Model {
static table = "posts";
static timestamps = true;
// override the column names if yours differ:
// static createdAtColumn = "inserted_at";
// static updatedAtColumn = "modified_at";
}
const post = await Post.create({ title: "Hi" });
post.created_at; // set
post.updated_at; // set (same instant)
Pagination
Model.paginate(page, perPage) returns a page of models plus metadata:
const { data, total, currentPage, lastPage, perPage } = await Post.paginate(2, 15);
data is Post[]; the rest is pagination state (defaults: page 1, 15 per
page). The query builder has the same paginate() if you're not using models.
Attribute casts
By default columns are whatever the driver returns (SQLite hands back 1/0
for booleans, strings for JSON). Declare static casts and values round-trip as
real JS types — cast when read (from the database or fill) and back to storable
primitives when written:
class Post extends Model {
static table = "posts";
static casts = {
published: "boolean", // 1/0 <-> true/false
views: "int", // "10" -> 10
meta: "json", // '{"a":1}' <-> { a: 1 } (also "array")
posted_at: "date", // ISO string <-> Date
} as const;
}
const post = await Post.find(1);
post.published; // true (a real boolean)
post.meta; // { … } (a real object)
post.published = false;
await post.save(); // stored as 0; meta re-serialized to a JSON string
The as const keeps the string literals from widening to string so the map
still satisfies Casts (Record<string, CastType>) — without it TypeScript
infers string values and the assignment to the base static casts fails.
Casts are what let a boolean or json column bind cleanly on real drivers,
which reject JS booleans and objects as parameters. Supported types: int (alias
integer), float (alias number), boolean (alias bool), string, json
/ array, date. Both directions are null-safe — null/undefined pass
through uncast — and reads are tolerant of already-cast input, so hydrating a row
twice or casting a value that's already a Date is a no-op.
Mass assignment
create() and fill() take untrusted input (often a request body), so they're
guarded. Whitelist columns with static fillable, or blacklist with
static guarded — columns outside the allowance are silently dropped:
class Post extends Model {
static table = "posts";
static fillable = ["title", "body"]; // only these are mass-assignable
// — or —
static guarded = ["is_admin"]; // everything except these
}
await Post.create({ title: "Hi", is_admin: true }); // is_admin dropped
post.fill(request.all()); // safe from over-posting
post.forceFill({ is_admin: true }); // explicit bypass
With neither declared, all attributes are assignable (the default). Direct
property assignment (post.is_admin = true) is never guarded — guarding is only
about mass assignment from untrusted data.
Serializing
user.toJSON(); // a plain object of the (cast) attributes + loaded relations
return json(user); // works directly — json() serializes it
user.fill({ name: "X" }); // merge mass-assignable attributes without saving
Control what toJSON() exposes with three statics. hidden strips columns;
visible is an allowlist that wins over everything; appends adds computed
attributes — a getter or a zero-arg method on the model:
class User extends Model {
static table = "users";
static hidden = ["password"]; // never serialized
static appends = ["fullName"]; // added to the output
get fullName() { return `${this.first} ${this.last}`; }
}
Lifecycle events
A model fires events as it is retrieved, saved, and deleted. Hook onto them to
slug a title, bust a cache, or cascade — without touching every call site. The
*ing events are cancelable: a hook returning false aborts the write.
User.creating((user) => { user.uuid = crypto.randomUUID(); });
User.saved((user) => cache().forget(`user:${user.id}`));
User.deleting((user) => (user.isRoot ? false : undefined)); // veto
// Or group them in an observer:
User.observe({
creating: (u) => { u.uuid = crypto.randomUUID(); },
deleted: (u) => audit(`deleted ${u.id}`),
});
Events: retrieved, creating/created, updating/updated,
saving/saved, deleting/deleted, restoring/restored.
Hooks inherit, ancestors first: a hook on a base class fires for every model
that extends it. That's what lets a base class do real work — a creating hook
that stamps a tenant id is useless if subclasses never fire it.
Query scopes
A global scope constrains every query a model builds — the base for multi-tenancy, published-only reads, and soft deletes:
Post.addGlobalScope("published", (q) => q.where("published", true));
await Post.all(); // only published
await Post.query().where("author_id", 1).get(); // still only published
Scopes inherit. A scope declared on a base class constrains every model that extends it — which is what makes a single tenant-scoped base class possible:
class TenantModel extends Model {}
TenantModel.addGlobalScope("tenant", (q) => q.where("teamId", currentTeamId()));
class Post extends TenantModel {} // scoped, without repeating yourself
Scopes from several levels all apply, and a subclass overrides an ancestor's scope by reusing its name — the nearest declaration wins.
Escaping a scope
await Post.withoutGlobalScope("tenant").get(); // one named scope
await Post.withoutGlobalScopes().get(); // all of them
Escaping is deliberately explicit, and worth keeping that way. A query that steps
outside a tenancy scope is exactly the thing you want to be able to find — so it
should be typed out and greppable, never something you arrive at by forgetting a
where.
A local scope is just a static method returning a query — no framework feature needed:
class Post extends Model {
static popular() { return this.query().where("views", ">", 1000); }
}
await Post.popular().orderBy("views", "desc").get();
Soft deletes
Opt in with static softDeletes = true and a deleted_at column. delete()
then sets the timestamp instead of removing the row, and a global scope hides
soft-deleted rows from every query.
class User extends Model {
static table = "users";
static softDeletes = true;
static casts = { deleted_at: "date" };
}
await user.delete(); // sets deleted_at; row stays in the table
user.trashed(); // true
await User.find(user.id); // null — hidden by the scope
await User.withTrashed().get(); // include soft-deleted
await User.onlyTrashed().get(); // only soft-deleted
await user.restore(); // clear deleted_at
await user.forceDelete(); // remove the row for good
Relationships
Define a relationship as a method that returns one of hasMany / hasOne /
belongsTo / belongsToMany. Keys follow conventions (the owning model's name
plus its primary key — user_id) but every one is overridable.
class User extends Model {
static table = "users";
posts() { return this.hasMany(Post); } // posts.user_id = users.id
profile() { return this.hasOne(Profile); } // profiles.user_id = users.id
roles() { return this.belongsToMany(Role); } // role_user pivot
}
class Post extends Model {
static table = "posts";
author() { return this.belongsTo(User); } // posts.user_id -> users.id
}
Relations are awaitable — read them lazily with await:
const posts = await user.posts(); // Post[]
const author = await post.author(); // User | null
Need to constrain or sort? .query() hands back the underlying query builder:
const recent = await user.posts().query().orderBy("created_at", "desc").limit(5).get();
Eager loading (avoiding N+1)
Loading a relation per model in a loop is N+1 queries. Model.load() fetches
them all with one extra query per relation, using whereIn:
const users = await User.all();
await User.load(users, "posts", "roles"); // 2 extra queries total, not 2×N
users[0].getRelation("posts"); // Post[]
users[0].toJSON(); // includes `posts` and `roles`
Loaded relations are stored off the model, so they never leak into save(),
and toJSON() serializes them (nested models included).
Querying relationships (with, withCount, whereHas)
Model.query() returns a model-aware builder with the relationship operations a
raw query can't express. with() eager-loads (dotted paths nest), withCount()
adds a <relation>_count, and has/whereHas/doesntHave filter by whether a
related row exists:
const users = await User.query()
.where("active", true)
.with("posts.comments") // nested eager load
.withCount("posts") // users[i].posts_count
.whereHas("posts", (q) => q.where("published", true))
.get();
await User.has("posts").get(); // users with at least one post
await User.doesntHave("posts").get(); // users with none
with/withCount/whereHas/has/doesntHave are also static shortcuts
(User.with(...), User.whereHas(...)). Existence filters use the same
driver-agnostic two-query strategy as the relations themselves — no JOIN.
Many-to-many
belongsToMany reads through a pivot table (default name: the two table names
sorted and joined, e.g. role_user) and can write it too:
await user.roles().attach(roleId); // insert a pivot row
await user.roles().detach(roleId); // remove one (or all, with no argument)
await user.roles().sync([1, 2, 3]); // make the pivot contain exactly these
Every relation runs on the driver-agnostic query builder — no JOINs, no driver imports — so relationships stay edge-safe. Overriding keys:
this.hasMany(Post, "authored_by", "id");
this.belongsTo(User, "owner_id", "id");
this.belongsToMany(Role, "user_roles", "user_id", "role_id");
Polymorphic
A polymorphic relation lets one model belong to more than one type. The related
rows carry <name>_id + <name>_type; register each owner type so morphTo
can resolve it:
class Post extends Model {
comments() { return this.morphMany(Comment, "commentable"); }
}
class Video extends Model {
comments() { return this.morphMany(Comment, "commentable"); }
}
class Comment extends Model {
commentable() { return this.morphTo("commentable"); } // resolves back to Post or Video
}
registerMorphType("Post", Post);
registerMorphType("Video", Video);
await post.comments().create({ body: "nice" }); // sets commentable_id/_type
const owner = await comment.commentable(); // Post | Video | null
morphOne is the one-to-one variant. Eager loading (Model.load / with) works
across mixed types.
What this is (and isn't)
This is a compact active-record — CRUD, lifecycle events, scopes, soft deletes,
serialization control, eager loading (including nested with("posts.comments")),
relationship queries (whereHas/withCount), and polymorphic relations — all on
a driver-agnostic query builder, no ORM dependency. For complex one-off queries
you can always drop to db() or your driver directly.
API reference
Everything below imports from @shaferllc/keel/core.
Model — static methods
You call these on your subclass (User.find(1)), not on Model itself. Each
read hydrates rows into instances of the class it was called on.
Model.query()
static query(): QueryBuilder
Returns a raw query builder scoped to the model's table — the escape hatch for anything the finders don't cover.
const rows = await User.query().where("age", ">", 18).orderBy("name").limit(10).get();
Notes: returns plain Rows, not hydrated models — map them through
new User(row) yourself if you need instances.
Model.all()
static all<T extends Model>(this: ModelClass<T>): Promise<T[]>
Fetches every row in the table as hydrated models.
const users = await User.all(); // User[]
Notes: no where, no limit — it reads the whole table. Reach for query()
when that's too much.
Model.find(id)
static find<T extends Model>(this: ModelClass<T>, id: unknown): Promise<T | null>
Looks a model up by primary key. Resolves to null when nothing matches.
const user = await User.find(1); // User | null
Notes: matches on static primaryKey (default "id"). Returns null, not
undefined.
Model.findOrFail(id)
static findOrFail<T extends Model>(this: ModelClass<T>, id: unknown): Promise<T>
Like find, but throws instead of returning null.
const user = await User.findOrFail(1); // User (or throws)
Notes: throws NotFoundException with message "<ClassName> <id> not found". NotFoundException is an HttpException (status 404), so an HTTP
handler surfaces it as a 404 without extra work.
Model.first()
static first<T extends Model>(this: ModelClass<T>): Promise<T | null>
Returns the first row in the table (no ordering), or null.
const anyUser = await User.first(); // User | null
Notes: unordered — the "first" row is whatever the driver returns first. Add
your own orderBy via query().first() when order matters.
Model.where(column, value)
static where<T extends Model>(this: ModelClass<T>, column: string, value: unknown): Promise<T[]>
A convenience finder for a single equality condition. Runs immediately and returns hydrated models.
const active = await User.where("active", true); // User[]
Notes: equality only, and it's a terminal call — it returns a Promise, not
a builder, so you can't chain more constraints onto it. Use query() for
operators, OR, ordering, or limits.
Model.create(attributes)
static create<T extends Model>(this: ModelClass<T>, attributes: Row): Promise<T>
Mass-assigns attributes (filtered through fillable/guarded), inserts one
row, and returns the hydrated model with its new primary key set.
const user = await User.create({ email: "a@b.com", name: "Ada" });
user.id; // populated from insertId
Notes: attributes outside the mass-assignment allowance are silently dropped
before the insert. Values are cast to storable primitives on the way in. If the
driver doesn't report an insertId, the primary key stays unset.
Model.load(models, ...names)
static load<T extends Model>(models: T[], ...names: string[]): Promise<T[]>
Eager-loads one or more relationships onto an array of already-fetched models — one extra query per relation, the fix for N+1. Returns the same array.
const users = await User.all();
await User.load(users, "posts", "roles"); // 2 extra queries, not 2×N
users[0].getRelation("posts"); // Post[]
Notes: each name must be a relationship method on the model; an unknown name
throws "<ClassName> has no relation "<name>"". An empty models array is
returned untouched (no queries). Loaded results are stored off the model (see
getRelation) and never leak into save().
Model.filterFillable(attributes)
static filterFillable(attributes: Row): Row
Returns a copy of attributes keeping only what mass-assignment allows — the
guard create/fill apply. Rarely called directly.
const safe = Post.filterFillable(request.all());
Notes: if fillable is non-empty it's an allowlist; else if guarded is
non-empty it's a denylist; with neither, everything passes. fillable wins when
both are set.
Model.toDatabase(attributes)
static toDatabase(attributes: Row): Row
Casts attributes to their storable primitives (via castSet) for a write.
Rarely called directly — create/save use it internally.
const storable = Post.toDatabase({ published: true }); // { published: 1 }
Model.with(...names) · Model.withCount(...names)
Start a ModelQuery that eager-loads the named relations (dotted
paths nest: "posts.comments") or counts them into <relation>_count.
Model.has(name) · Model.whereHas(name, constrain?) · Model.doesntHave(name, constrain?)
Start a ModelQuery filtered by relationship existence — has at least one
related row, has one matching constrain(query), or has none. constrain
receives the related-table query builder.
Model.newQuery()
static newQuery(): ModelQuery<T>
The model-aware query behind the sugar above — hydrates rows to models and adds
with/withCount/whereHas.
Model.addGlobalScope(name, scope)
static addGlobalScope(name: string, scope: (query: QueryBuilder) => void): void
Register a constraint applied to every query the model builds. Inherited by subclasses; a subclass re-using a name overrides it.
Model.withTrashed() · Model.onlyTrashed() · Model.withoutGlobalScope(...names) · Model.withoutGlobalScopes()
Escape hatches returning a QueryBuilder: include (or only) soft-deleted rows,
or drop named / all global scopes. Deliberately explicit so an unscoped query is
greppable at audit time.
Model — lifecycle events
Register per-class hooks (keyed by the exact class). The *ing events are
cancelable — a hook returning false aborts the operation.
Model.creating · created · updating · updated · saving · saved · deleting · deleted · restoring · restored · retrieved
static <event>(hook: (model: T) => void | boolean | Promise<void | boolean>): void
Add a hook for that lifecycle event. create() fires saving→creating→write→
created→saved; a save that updates fires the updating/updated pair.
Model.observe(observer)
static observe(observer: Partial<Record<ModelEvent, ModelHook<T>>>): void
Attach an observer object — each method named after an event becomes a hook.
Model — configuration statics
Set these on the subclass to configure it. All have defaults.
static table
static table: string
The table the model reads and writes. Required — defaults to "".
class User extends Model { static table = "users"; }
static primaryKey
static primaryKey: string
The primary-key column used by find, save, and delete. Defaults to "id".
class Session extends Model { static table = "sessions"; static primaryKey = "token"; }
static fillable
static fillable: string[]
Allowlist of mass-assignable columns. Defaults to [] (meaning "not an
allowlist" — see filterFillable).
class Post extends Model { static table = "posts"; static fillable = ["title", "body"]; }
static guarded
static guarded: string[]
Denylist of columns that mass-assignment must never set. Ignored when fillable
is non-empty. Defaults to [].
class Post extends Model { static table = "posts"; static guarded = ["is_admin"]; }
static casts
static casts: Casts
Maps columns to cast types so values round-trip as real JS types. Declare it
as const so the literals don't widen to string. Defaults to {}.
class Post extends Model {
static table = "posts";
static casts = { published: "boolean", meta: "json" } as const;
}
static hidden / static visible / static appends
static hidden: string[] · static visible: string[] · static appends: string[]
Shape toJSON(): hidden strips columns, visible is an allowlist that wins,
appends adds computed attributes (a getter or zero-arg method). All default [].
static softDeletes / static deletedAtColumn
static softDeletes: boolean (default false) · static deletedAtColumn: string
(default "deleted_at")
Turn on soft deletes: delete() sets the timestamp and a global scope hides
trashed rows.
Model — instance methods
new Model(attributes?)
constructor(attributes?: Row)
Hydrates a model from a row. Assignment is unguarded (rows come from the
database) but every column named in casts is cast on the way in.
const draft = new User({ email: "new@x.com" });
Notes: hydration bypasses fillable/guarded — it's for trusted rows, not
request bodies. Use create/fill for untrusted input.
save()
save(): Promise<this>
Inserts when the primary key is absent, updates when it's present — one method for both. Back-fills the primary key after an insert.
const u = new User({ email: "a@b.com" });
await u.save(); // INSERT; u.id now set
u.name = "Grace";
await u.save(); // UPDATE where id = u.id
Notes: writes every own column (cast to storable primitives); loaded relations live off-instance and never leak in. An update with no changed columns still issues the query.
delete()
delete(): Promise<void>
Deletes the row matching this model's primary key — or, with static softDeletes
on, sets deleted_at instead. Fires deleting/deleted.
await user.delete();
Notes: keys off the current primaryKey value. See forceDelete/restore
for the soft-delete variants.
forceDelete() · restore() · trashed()
forceDelete(): Promise<void> · restore(): Promise<this> · trashed(): boolean
For soft-deletable models: permanently remove the row, clear deleted_at
(fires restoring/restored), or test whether it's currently trashed.
fill(attributes)
fill(attributes: Row): this
Merges mass-assignable attributes into the model (filtered + cast), without
saving. Returns this for chaining.
user.fill(request.all()).save();
Notes: respects fillable/guarded — safe for request bodies. Doesn't touch
the database until you call save().
forceFill(attributes)
forceFill(attributes: Row): this
Like fill, but bypasses mass-assignment guarding. Still casts.
user.forceFill({ is_admin: true }); // deliberate over-post
Notes: the explicit escape hatch — only use it with trusted data.
toJSON()
toJSON(): Row
Returns a plain object of the model's (cast) attributes plus any loaded
relations, nested models included. JSON.stringify and json() call it
automatically.
return json(user); // toJSON() runs under the hood
user.toJSON(); // { id, email, …, posts: [...] } if `posts` was loaded
Notes: only loaded relations appear — unloaded relationship methods are not
invoked. Relations serialize recursively via each nested model's toJSON.
getRelation(name)
getRelation<T = unknown>(name: string): T | undefined
Reads a relation previously loaded by Model.load (or setRelation). Returns
undefined if it was never loaded.
const posts = users[0].getRelation<Post[]>("posts");
Notes: does not trigger a query — it only reads what's already cached. Awaiting
the relationship method (await user.posts()) is the lazy alternative.
setRelation(name, value)
setRelation(name: string, value: unknown): this
Stores a relation result under name (what eager loading uses under the hood).
Returns this.
user.setRelation("posts", await user.posts());
Notes: the store is keyed off the instance (a WeakMap), so it never leaks
into save(); toJSON() picks it up.
hasMany(related, foreignKey?, localKey?)
hasMany<T extends Model>(related: ModelClass<T>, foreignKey?: string, localKey?: string): HasMany<T>
Declares a one-to-many: this model has many related rows joined by a foreign
key on the related table. Call it from a relationship method.
posts() { return this.hasMany(Post); } // posts.user_id = users.id
authored() { return this.hasMany(Post, "authored_by", "id"); }
Notes: foreignKey defaults to <thismodel>_<primaryKey> (e.g. user_id);
localKey defaults to this model's primary key.
hasOne(related, foreignKey?, localKey?)
hasOne<T extends Model>(related: ModelClass<T>, foreignKey?: string, localKey?: string): HasOne<T>
Declares a one-to-one, same key conventions as hasMany.
profile() { return this.hasOne(Profile); } // profiles.user_id = users.id
Notes: resolves to a single model or null (the first matching row).
belongsTo(related, foreignKey?, ownerKey?)
belongsTo<T extends Model>(related: ModelClass<T>, foreignKey?: string, ownerKey?: string): BelongsTo<T>
Declares the inverse: this model carries the foreign key pointing at related.
author() { return this.belongsTo(User); } // posts.user_id -> users.id
owner() { return this.belongsTo(User, "owner_id", "id"); }
Notes: foreignKey defaults to <related>_<related.primaryKey> (a column on
this table); ownerKey defaults to the related model's primary key. Resolves to
null when the foreign key is null.
belongsToMany(related, pivotTable?, foreignPivotKey?, relatedPivotKey?, parentKey?, relatedKey?)
belongsToMany<T extends Model>(related: ModelClass<T>, pivotTable?: string, foreignPivotKey?: string, relatedPivotKey?: string, parentKey?: string, relatedKey?: string): BelongsToMany<T>
Declares a many-to-many through a pivot table.
roles() { return this.belongsToMany(Role); } // role_user pivot
roles() { return this.belongsToMany(Role, "user_roles", "user_id", "role_id"); }
Notes: pivotTable defaults to the two model names lowercased, sorted, and
joined with _ (User + Role → role_user). The pivot keys default to
<model>_<primaryKey>. Reads as two whereIn queries (no JOIN), so it stays
edge-safe.
morphMany(related, name, localKey?) · morphOne(related, name, localKey?)
morphMany<T>(related: ModelClass<T>, name: string, localKey?: string): MorphMany<T>
The parent side of a polymorphic relation. Related rows carry <name>_id +
<name>_type (the type stored is this model's class name). MorphMany also has
.create(attributes), which fills the morph keys.
comments() { return this.morphMany(Comment, "commentable"); }
morphTo(name, idColumn?, typeColumn?)
morphTo(name: string, idColumn?: string, typeColumn?: string): MorphTo
The owning side — resolves the parent from the stored <name>_type (via
registerMorphType) and <name>_id. Awaitable;
returns the parent model or null.
commentable() { return this.morphTo("commentable"); }
registerMorphType(type, model)
registerMorphType(type: string, related: ModelClass<Model>): void
Register a model under a morph-type string (usually its class name) so morphTo
can resolve it. Call once at boot for each owner type.
ModelQuery
The model-aware builder returned by Model.query(), Model.newQuery(), and the
with/whereHas/withCount shortcuts. It proxies the query-builder constraint
methods (where, orderBy, limit, …) and hydrates results to models, adding:
with(...names)— eager-load relations; dotted paths nest ("posts.comments").withCount(...names)— add<relation>_countto each result.has(name)/whereHas(name, constrain?)/doesntHave(name, constrain?)— filter by relationship existence.- Terminals
get(),first(),count(),exists(),paginate(page?, perPage?).
Existence filters and counts use the same driver-agnostic two-query strategy as
the relations (no JOIN). toBase() returns the underlying QueryBuilder.
Relations
You never new these — a relationship method (user.posts()) returns one. Each
is awaitable: awaiting it runs the query and resolves to the result. All
four share the Relation base contract (query, get, eager, then);
BelongsToMany adds pivot writes.
Relation (abstract base)
abstract class Relation<TRelated extends Model, TResult> implements PromiseLike<TResult>
The shared base. Because it's PromiseLike, a relation resolves through await
or .then() without calling get() explicitly.
const posts = await user.posts(); // then() → get()
const post = await user.posts().get(); // same thing, explicit
query()
query(): QueryBuilder
Returns the underlying query builder with the relationship constraint applied — constrain, sort, or paginate before fetching.
const recent = await user.posts().query().orderBy("created_at", "desc").limit(5).get();
Notes: for belongsToMany, query() is the related-table builder without
the pivot filter — prefer get()/await for the full pivot-aware read.
get()
get(): Promise<TResult>
Runs the relationship and returns its result — the type depends on the subclass (see below).
eager(models, name)
eager(models: Model[], name: string): Promise<void>
Batch-loads this relationship onto many parents and stores each result via
setRelation. Called by Model.load — you rarely call it directly.
then(onFulfilled?, onRejected?)
then<R1, R2>(onFulfilled?, onRejected?): PromiseLike<R1 | R2>
The PromiseLike hook that makes a relation awaitable; it delegates to get().
HasMany.get()
get(): Promise<T[]>
Returns all related rows as hydrated models (empty array when none).
const posts: Post[] = await user.posts();
HasOne.get()
get(): Promise<T | null>
Returns the single related model, or null.
const profile = await user.profile(); // Profile | null
BelongsTo.get()
get(): Promise<T | null>
Returns the owner model, or null when this model's foreign key is null.
const author = await post.author(); // User | null
BelongsToMany.get()
get(): Promise<T[]>
Reads the pivot rows, then the related rows they point at, as hydrated models.
const roles: Role[] = await user.roles();
Notes: related ids are de-duplicated, so a row linked twice through the pivot appears once.
BelongsToMany.attach(id, extra?)
attach(id: unknown, extra?: Row): Promise<void>
Inserts one pivot row linking the parent to id, plus any extra pivot columns.
await user.roles().attach(roleId);
await user.roles().attach(roleId, { assigned_at: now });
Notes: no uniqueness check — attaching the same id twice inserts two pivot rows unless the table constrains it.
BelongsToMany.detach(id?)
detach(id?: unknown): Promise<void>
Removes the pivot row for id, or all the parent's pivot rows when called
with no argument.
await user.roles().detach(roleId); // one link
await user.roles().detach(); // every link for this user
BelongsToMany.sync(ids)
sync(ids: unknown[]): Promise<void>
Makes the pivot contain exactly ids — detaches everything, then attaches each.
await user.roles().sync([1, 2, 3]);
Notes: not diff-based — it detaches all then re-attaches, so passing []
clears every link. Runs one delete plus one insert per id (not a transaction).
Interfaces & types
CastType
type CastType =
| "int" | "integer" | "float" | "number"
| "boolean" | "bool" | "string"
| "json" | "array" | "date";
The supported cast kinds — the values in a casts map. Aliases pair up
(int/integer, float/number, boolean/bool, json/array).
const kind: CastType = "boolean";
Casts
type Casts = Record<string, CastType>
A column-to-cast-type map — the shape of static casts. Declare literal maps
as const so the string values don't widen past CastType.
const casts: Casts = { published: "boolean", meta: "json" };
Casting internals
castGet, castSet, and applyCasts (in src/core/casts.ts) are the functions
that power casting — castGet maps storage → JS, castSet maps JS → storage, and
applyCasts runs one of them over the keys named in a Casts map. They're
internal plumbing: the Model uses them for you and they aren't re-exported from
@shaferllc/keel/core, so declaring static casts is all you need.