Features Apograph CMS on GitHub

Relations

Content plugin @apograph/content-server@apograph/content-admin

Four cardinalities out of two storage forms, and the inverse side that stores nothing at all.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

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

ShapeHow it is declared
Many-to-oneA plain single relation, field.relation({ to }). The foreign key sits on this row.
One-to-manyThe inverse of that. It owns no storage — model it as the single relation on the “many” side.
One-to-oneA single relation with unique: true.
Many-to-manymany: true, which generates the join table and keeps the ordering.

field.relation

OptionDefaultEffect
toA thunk returning the target content type. Required.
manyfalseMany-to-many, via a generated join table.
onDeletesee belowWhat happens to this row when the target is deleted.
uniquefalseOne-to-one. Invalid with many.
requiredfalseThe link must be present.
syncAcrossLocalestrueWhether 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.

ValueEffect
'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' })
OptionDefaultEffect
ofA thunk returning the type that owns the storage.
fieldThe relation field name on of whose links this side mirrors.
manytrueWhether 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 to true. localized: true on a relation is an alias for false; 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.
BehaviourWhenWhat is stored
sharedsync on, target is not i18nThe same target id in every sibling row
mirroredsync on, target is i18nThe target’s group, resolved to that language’s row
nonesync off, or an inverse, or a non-i18n ownerNothing 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.