Two storage forms cover all four cardinalities, and everything else is one of those two seen from a different angle.
A single relation is a foreign key column on the owning row. A many-relation generates a join table. That is the whole storage model.
export const article = collection('article', {
fields: {
// many-to-one: the FK lives on this row
author: field.relation({ to: () => author, required: true }),
// many-to-many: a join table, order preserved
tags: field.relation({ to: () => tag, many: true }),
// one-to-one: the same FK, with a UNIQUE constraint
seo: field.relation({ to: () => seo_meta, unique: true }),
// one-to-many: the inverse of comment.article — stores nothing
comments: field.relationInverse({ of: () => comment, field: 'article' })
}
});The four cardinalities
| Shape | How it is declared |
|---|---|
| Many-to-one | A plain single relation, field.relation({ to }). The foreign key sits on this row. |
| One-to-many | The inverse of that. It owns no storage — model it as the single relation on the “many” side. |
| One-to-one | A single relation with unique: true. |
| Many-to-many | many: true, which generates the join table and keeps the ordering. |
field.relation
| Option | Default | Effect |
|---|---|---|
to | — | A thunk returning the target content type. Required. |
many | false | Many-to-many, via a generated join table. |
onDelete | see below | What happens to this row when the target is deleted. |
unique | false | One-to-one. Invalid with many. |
required | false | The link must be present. |
syncAcrossLocales | true | Whether editing the link in one locale reaches the others. |
The target is a thunk
to: () => author, not to: author. Two collection files routinely import each
other — an article names its author, an author lists their articles — and a
thunk is what lets that resolve. If TypeScript complains about a circular
inference, annotate the thunk’s return type to break the cycle:
import { collection, field, type AnyContentType } from '@apograph/content-server/define';
author: field.relation({
to: (): AnyContentType => author,
required: true
})onDelete
What happens to this row when the row it points at is deleted. It defaults to
'set null', or to 'cascade' when the relation is required.
| Value | Effect |
|---|---|
'set null' | The link is cleared. The default for an optional relation. |
'cascade' | This row is deleted too. The default for a required relation. |
'restrict' | The delete is refused while anything still points here. |
A required relation with onDelete: 'set null' is rejected at define time:
the column is NOT NULL, so it cannot be nulled on delete, and the combination
could only ever fail at runtime.
For a required relation, the real choice is between 'cascade' (deleting an
author deletes their articles) and 'restrict' (deleting an author is refused
while they have any). 'restrict' is usually what you want for editorial data:
author: field.relation({
to: () => author,
required: true,
onDelete: 'restrict'
})onDelete is ignored on a many-relation — join rows simply disappear with
either side.
unique
unique: true makes a single relation one-to-one by putting a UNIQUE
constraint on the <field>_id column. It is nullable-unique, so any number of
rows may have no link at all; what it forbids is two owners claiming the same
target.
Combining unique with many is rejected at define time — a join table has no
column to constrain.
Uniqueness is enforced in two places on purpose. The index is the guarantee, because it is the only thing that holds under concurrent writes; the service layer checks first purely for the error message, turning what would be a 500 with a Postgres constraint name into the same field-level 422 as every other validation failure.
field.relationInverse
The other side of a relation, declared on the type that does not own the storage.
// comment owns the link
export const comment = collection('comment', {
fields: {
article: field.relation({ to: () => article, required: true })
}
});
// article reads it back
comments: field.relationInverse({ of: () => comment, field: 'article' })| Option | Default | Effect |
|---|---|---|
of | — | A thunk returning the type that owns the storage. |
field | — | The relation field name on of whose links this side mirrors. |
many | true | Whether this side is to-many. |
An inverse emits no column and no join table. Reads and writes reuse the owning side’s storage with source and target swapped, which means editing an article’s comments and editing a comment’s article move the same rows — the two sides cannot drift apart the way two mirrored columns eventually do.
Two useful consequences:
- Adding an inverse produces no migration. There is nothing new to store.
- A broken pairing fails at boot. The registry checks that the named field exists on the target, is storage-owning, and points back — so a typo stops the server starting rather than surfacing on the first request.
Join tables
A many-relation generates a table called content_<type>_<field>. Declaring
tags on article produces content_article_tags. The table preserves link
order, so a hand-ordered list of related articles stays in that order.
Every join table must be re-exported from your schema entry file or drizzle-kit will not see it:
export const articleTagsJoinTable = joinTableOf(article, 'tags');joinTableOf throws when the named relation is missing or renamed, so a
forgotten export fails at build instead of quietly dropping the table from the
migration diff.
Relations across locales
On a localized type, a relation belongs to the record, not to the language. Tagging the English article tags the German one too, by default.
Two things decide what happens, and only one of them is yours:
- You set
syncAcrossLocales, which defaults totrue.localized: trueon a relation is an alias forfalse; declaring both in contradiction is rejected at define time. - The target type decides how a synced link is stored, because that is a storage fact rather than a preference.
| Behaviour | When | What is stored |
|---|---|---|
shared | sync on, target is not i18n | The same target id in every sibling row |
mirrored | sync on, target is i18n | The target’s group, resolved to that language’s row |
none | sync off, or an inverse, or a non-i18n owner | Nothing propagates |
mirrored is the interesting one. If both article and tag are localized, then
tagging the English article with the English tag must tag the German article
with the German tag — a shared foreign key there would be a cross-locale link,
which the system forbids. So the link is resolved per language.
Cardinality does not enter into it: a single FK, an owning many-to-many, and their join tables all follow the same rule.
One-to-one on a localized type
unique: true means one-to-one per record, and on a localized type a record
is N rows. So the constraint is scoped to the locale: instead of a column-wide
UNIQUE, the table gets a unique index on (<field>_id, locale).
That is what allows the English and German rows of one article to share a single
SEO record — which is exactly what a shared relation does — while a different
article still cannot claim it. A column-wide UNIQUE would have meant “one
row may point here”, making the second translation of any such record a flat
constraint violation.
Relations are validated, not just constrained
Before a write lands, the service checks that every relation target actually exists and is in the same workspace. A missing target and a target in another workspace produce the same 422, deliberately — answering them differently would let a caller enumerate ids they cannot otherwise see.