Relations Overview
Define ordinary and polymorphic relationships between models
Two Factories
VibORM has exactly two relation factories. The one you call states the slot cardinality — how many records this side addresses — and its argument states the target domain — one model, or a map of named variants:
s.toOne(() => targetModel).fields("fk").references("id")
The thunk () => targetModel comes first to handle circular references between models, then configuration methods are chained.
Everything else about a relationship — which endpoint stores the foreign key, whether the pair is one-to-one or many-to-one, whether storage is a foreign key or a junction table, whether a singular slot may be empty — is derived from the whole schema. You never declare it twice.
| Factory | Target argument | Meaning |
|---|---|---|
s.toOne(() => model) |
one model | this side addresses at most one record |
s.toMany(() => model) |
one model | this side addresses a collection |
s.toOne({ ... }) |
a variant map | at most one record, of one of several models |
s.toMany({ ... }) |
a variant map | a collection whose members may mix models |
Cardinality Is the Pair
A relationship is two slots. Its familiar name is what the pair spells, not a factory you call:
| Both endpoints | Relationship | Storage |
|---|---|---|
toOne + toOne |
one-to-one | a foreign key on the endpoint that declares .fields(...) , under a unique constraint |
toOne + toMany |
many-to-one / one-to-many | a foreign key on the toOne endpoint |
toMany + toMany |
many-to-many | a junction table |
Every ordinary relation needs a complete inverse: a slot whose target model
declares nothing back is refused with R002. A toOne/toOne or
toOne/toMany pair also needs exactly one physical owner — the endpoint that
completes .fields(...).references(...). None is FK004; both is CM003.
Quick Example
import { s } from "viborm";
const user = s.model({
id: s.string().id().ulid(),
email: s.string().unique(),
profile: s.toOne(() => profile),
posts: s.toMany(() => post),
});
const profile = s.model({
id: s.string().id().ulid(),
bio: s.string(),
userId: s.string().unique(),
user: s.toOne(() => user)
.fields("userId")
.references("id"),
});
const post = s.model({
id: s.string().id().ulid(),
title: s.string(),
authorId: s.string(),
author: s.toOne(() => user)
.fields("authorId")
.references("id"),
tags: s.toMany(() => tag),
});
const tag = s.model({
id: s.string().id().ulid(),
name: s.string().unique(),
posts: s.toMany(() => post),
});
user.profile and profile.user are both toOne, so the pair is one-to-one and
profile.userId carries the foreign key. user.posts is a collection whose
partner post.author is singular, so the pair is one-to-many and post.authorId
carries the foreign key. post.tags and tag.posts are both collections, so the
pair is many-to-many and gets a junction table.
Methods by Slot
Singular slots (s.toOne)
s.toOne(() => target)
.name("author") // Pairing label, matched exactly on both endpoints
.fields("authorId") // FK field(s) on THIS model — makes this side the owner
.references("id") // Referenced field(s) on the target model
.onDelete("cascade") // Referential action on delete
.onUpdate("cascade") // Referential action on update
.fields(...) needs at least one field and must reach .references(...) with
the same number of fields; the value in between is not a relation and s.model()
refuses it. .onDelete() / .onUpdate() appear only after the reference is
complete, because there is no foreign key to act on before it.
A singular slot that declares no foreign key is the non-owning side. It needs no configuration at all, and it is always nullable in results: the membership lives on the other row and may simply be missing.
There is no .optional(). Whether an owner’s slot may be empty follows from the
foreign-key scalars themselves — if any member of the tuple is .nullable(), the
membership is absent-able and disconnect is offered.
const post = s.model({
authorId: s.string().nullable(), // ← this is the optionality
author: s.toOne(() => user).fields("authorId").references("id"),
});
Collection slots (s.toMany)
s.toMany(() => target)
.name("tags") // Pairing label, matched exactly on both endpoints
.through("post_tags") // Junction table name
.source("postId") // This side's column, or prefix for a compound key
.target("tagId") // The other side's column, or prefix for a compound key
.onDelete("cascade") // Referential action (applies to both junction FKs)
.onUpdate("cascade") // Referential action (applies to both junction FKs)
.through(), .source(), .target(), .onDelete() and .onUpdate() describe
one physical junction, so exactly one endpoint carries them. Configuring both
sides is refused with R011 — the mirrored view is derived for the other side.
.source() is always oriented from the endpoint that declares it.
.source() and .target() always take one string. For a one-field primary key
that string is the exact junction column name. For a compound primary key it is
a prefix: .source("post") expands to post_1, post_2, and so on in the
primary key’s declared field order.
A collection paired with a singular slot owns no junction and needs no configuration: the foreign key lives on the singular side.
Variant slots (polymorphic)
s.toOne({
post: () => post, // A map argument makes the target domain variant:
video: () => video, // this slot holds at most one membership
})
.optional() // Both private storage columns may be null
.name("commentable") // Pairing label for inverse relations
The same two factories carry variant targets; only the argument changes.
s.toOne({ ... }) holds at most one membership, stored as a private
(type, id) pair on the owner row, and it is the one relation shape that keeps
.optional() — that flag is the nullability of those two private columns.
s.toMany({ ... }) holds a collection of memberships that may mix variants,
stored as one junction table per variant, and names those junctions through an
exact .through({ variant: { table, source, target } }) map. It has no
.optional(): an empty collection is already the empty case.
The public target keys are also the stored discriminators by default. Pass an
exact { values } second argument only when you need custom persistent values.
See Polymorphic Relations.
Relation Names
.name() is a pairing label, not a column. It matters when two models are joined
by more than one relationship, or when a model relates to itself: the two
endpoints of one relationship must claim the same exact name, and slots
claiming different names never pair.
const user = s.model({
authored: s.toMany(() => post).name("authored"),
reviewed: s.toMany(() => post).name("reviewed"),
});
A name that no candidate on the target model matches is R010. Several unnamed
candidates between the same two models are ambiguous and reported as R009.
Referential Actions
.onDelete() and .onUpdate() accept one of four actions:
| Action | Description |
|---|---|
cascade |
Delete/update related records |
setNull |
Set FK to NULL (requires every foreign-key member to be .nullable()) |
restrict |
Prevent delete/update if related records exist |
noAction |
Database default behavior |
Junction actions accept only cascade, restrict and noAction: every junction
column carries the membership itself, so there is nothing there to null.
This is the canonical reference — the per-relationship pages link back here.