L4 - Relations
How ordinary and polymorphic relations declare cardinality, storage, and inverse topology
Location: src/schema/relation/
Why This Layer Exists
ORMs need to express database relationships in a type-safe way. The challenge: models reference each other circularly.
const user = s.model({
id: s.string().id(),
posts: s.toMany(() => post), // User references Post
});
const post = s.model({
id: s.string().id(),
authorId: s.string(),
author: s.toOne(() => user) // Post references User
.fields("authorId")
.references("id"),
});
JavaScript can’t reference a variable before it’s declared. We solve this with thunks - functions that defer evaluation until the model is used.
Two Declared Facts
A declaration states exactly two things: the slot cardinality its factory was spelled with, and the target domain its argument names.
| Declaration | Slot cardinality | Target domain |
|---|---|---|
s.toOne(() => model) |
one | one model |
s.toMany(() => model) |
many | one model |
s.toOne({ ... }) |
one | named variants |
s.toMany({ ... }) |
many | named variants |
Everything else is DERIVED, once per schema, by the topology owner in
schema/validation/relation-resolution.ts:
| Derived fact | From |
|---|---|
| the partner slot | exact .name() match, then structural degree |
| foreign-key ownership | which endpoint completed .fields(...).references(...) |
| one-to-one vs many-to-one | the partner slot’s cardinality |
| junction vs foreign key | both endpoints being collections |
| remote uniqueness | the partner slot’s cardinality |
| may the slot be empty | the stored tuple’s nullability, or non-ownership |
The familiar cardinality cells are readings of a PAIR: toOne+toOne is
one-to-one, toOne+toMany is many-to-one/one-to-many, toMany+toMany is
many-to-many. A variant s.toOne stores private type and identity columns on
this model; a variant s.toMany stores one member junction table per variant.
Chainable API
Relations use a chainable API where the thunk comes first:
const post = s.model({
authorId: s.string(), // Foreign key column
author: s.toOne(() => user)
.fields("authorId") // Local FK column
.references("id"), // Remote PK column
});
Available Methods by Type
| Declaration | Methods |
|---|---|
s.toOne(() => model) |
.name(), .fields() → .references(), then .onDelete(), .onUpdate() |
s.toMany(() => model) |
.name(), .through(), .source(), .target(), .onDelete(), .onUpdate() |
s.toOne({ ... }) |
.name(), .optional() |
s.toMany({ ... }) |
.name(), .through() (an exact map keyed by public variant, each { table, source, target }) |
Both variant forms take a target MAP plus optional stable stored values, and both read, filter, count, write and migrate.
There is no .optional() on a model-target relation and no .unique()
anywhere. .fields(...) returns a transient stage that is NOT a relation — it
carries no brand, exposes only .references() and .name(), and s.model()
refuses it — so an incomplete foreign key can never become schema state. The
junction methods are meaningful only when the partner is also a collection, and
exactly one endpoint may carry them.
A s.toOne without .fields(...) is non-owning: it has no local foreign key
that can require the related row to exist, so its public slot is DERIVED
nullable. Slot emptiness and membership clearability stay separate facts:
delete can remove the child and leave the slot empty, while disconnect must
preserve the child and is valid only when the owning membership storage can be
cleared.
Bidirectional Relations
Both sides of a relation MUST be defined — a slot whose target model declares
nothing back is refused with R002:
// On User - the "one" side (doesn't own FK)
const user = s.model({
id: s.string().id(),
posts: s.toMany(() => post), // "I have many posts"
});
// On Post - the "many" side (owns FK)
const post = s.model({
id: s.string().id(),
authorId: s.string(),
author: s.toOne(() => user)
.fields("authorId")
.references("id"), // "I belong to one user"
});
VibORM uses these definitions to:
- Generate correct JOINs
- Enable nested queries (
where: { author: { name: "..." } }) - Support nested writes (
create: { author: { connect: {...} } })
Schema Relation and Bound Relation
L4 stores the durable schema declaration: slot cardinality, target domain, optional pairing name, and — where the author supplied them — the complete foreign key or the junction overrides. It stores no partner, no ownership flag and no derived optionality, and it does not decide how one nested mutation is executed.
When L6 reaches its first topology decision, bindRelation interprets that
declaration relative to the current model:
A bound relation answers three orthogonal questions, and each consumer asks the one it needs. There is one stored topology, several derived views:
| Axis | Values | What it decides |
|---|---|---|
position |
parentHeld · childHeld · junction |
Which row stores the membership — placement, and whether one membership can be shared by several source rows |
cardinality |
one · many |
How many targets the public slot admits — arity and payload shape, nothing about storage |
membership.kind |
foreignKey · polymorphic · junction |
How the membership is physically stored — which columns are written, read and compared |
The union forbids the impossible combinations rather than enumerating the legal
ones: a parent-held edge holds one foreign-key tuple per source row and is
therefore always to-one. Both child-held storages admit either arity, and so
does a junction — an ordinary junction pair is always to-many, but a member
junction whose target side carries a unique is a genuine singular junction slot.
That combination has exactly one producer, the polymorphic collection’s member
view; ordinary junction binding still writes many unconditionally.
The bound value carries the relation declaration, the source model, and the
membership. It carries no query scope, record identity, SQL, branch, or
execution policy. A non-owning s.toOne whose partner stores the FK is
therefore childHeld and to-one. A resolved inverse of a variant s.toOne
carrier is child-held with a polymorphic membership, because what it stores is
the private (type, identity) pair rather than an ordinary FK.
An inverse of a variant s.toMany carrier binds differently, and the three
axes are what make it cheap: its membership lives in that variant’s member
junction, so it binds as junction position with a junction membership,
supplied in reverse orientation. The plural inverse (an s.toMany) is then an
ordinary to-many junction edge and needs no polymorphic-specific code at all.
The singular inverse (a non-owning s.toOne) is the one shape the union
already admitted but nothing produced before: a junction position with
cardinality one, legal because its member table carries a unique over the
complete target side. Both directions read the same
ResolvedJunctionTopology, so the direct collection and its inverses can never
disagree about a member table.
BoundRelation also stays separate from RelationMutationProgram: one says
where an edge is stored, while the other says what the user requested. Relation
Parts combine those facts only when they own a selection, membership, or branch
decision.
Many-to-Many Relations
Many-to-many uses an implicit junction table:
const post = s.model({
id: s.string().id(),
title: s.string(),
tags: s.toMany(() => tag), // Auto-creates post_tag table
});
const tag = s.model({
id: s.string().id(),
name: s.string(),
posts: s.toMany(() => post),
});
The implicit table and non-self side tokens use the models’ schema object keys,
not their JavaScript variable names or .map() table names. A schema-key rename
therefore renames generated junction storage. The complete
.through().source().target() chain pins the table and both tokens.
With explicit junction table configuration — on ONE endpoint, the other side
reading the mirrored view (R011 refuses a second configuring endpoint):
tags: s.toMany(() => tag)
.through("post_tags") // Custom table name
.source("postId") // This endpoint's FK column
.target("tagId"), // The other endpoint's FK column
Polymorphic Collections
A variant s.toMany reuses that junction machinery rather than inventing a
heterogeneous one. The topology owner resolves each variant into a complete
member-junction topology, and junction-topology.ts is the single owner of that
resolution for ordinary pairs and variant members alike:
const shelf = s.model({
id: s.string().id(),
items: s.toMany({ book: () => book, video: () => video }),
});
// one ordinary-shaped junction per variant: shelf_items_book, shelf_items_video
Three consequences follow from “one fixed-target junction per variant”, and they are why the feature adds so little machinery:
- Real foreign keys. Each member table’s two sides reference real tables, so the database enforces membership. A single heterogeneous junction could not, which is why that design was rejected.
- Per-variant inverse cardinality. A unique over the complete target side makes that variant singular; its absence makes it plural. This is a per-member fact, unlike a row-held carrier’s relation-wide one.
- Orientation, not duplication. The direct collection and its inverses read
the same
ResolvedJunctionTopologyin opposite orientations, so a plural inverse is an ordinary many-to-many view and needs no polymorphic-specific code.
.through() overrides the generated names. Unlike the ordinary junction’s
fluent setters it takes one exact map keyed by public variant:
items: s
.toMany({ book: () => book, video: () => video })
.through({
book: { table: "shelf_books", source: "holder", target: "entry" },
video: { table: "shelf_videos", source: "holder", target: "entry" },
}),
Member defaults deliberately combine two namespaces: the table prefix is the
owner’s mapped SQL table, while the owner-side token comes from the owner schema
key. The target token comes from the public variant. An exact .through() map
pins table, source, and target for every member.
Why Thunks?
The () => model pattern is essential for two reasons:
1. Circular References
Without thunks, this would fail:
const user = s.model({
posts: s.toMany(post), // ❌ ReferenceError: post is not defined
});
const post = s.model({ ... });
2. TypeScript Inference
Thunks let TypeScript infer the return type before the variable is initialized:
// TypeScript can infer () => typeof post
// even though post isn't assigned yet
posts: s.toMany(() => post)
Connection to Other Layers
- L3 (Query Schemas): Relations generate nested schemas for include/select
- L6 (Query Engine): Binds relation storage positions for joins and nested mutations
- L12 (Migrations): Ordinary relations inform foreign-key constraints; a
variant
s.toOneslot adds private storage columns and a composite membership index because one database FK cannot target several tables; a variants.toManyslot adds one member junction table per variant, each with real foreign keys on both sides