Two storage forms, four cardinalities:
- Single relation — a
<field>_idforeign-key column on the owning row. - Many-relation — a generated, ordered join table.
A complete example
// apps/server/src/content/collections/article.ts
import { collection, field, type AnyContentType } from '@orthacms/content-server/define';
import { author } from './author';
import { comment } from './comment';
import { seo_meta } from './seo_meta';
import { tag } from './tag';
export const article = collection('article', {
publishable: true,
i18n: true,
fields: {
title: field.text({ required: true, localized: true }),
// many-to-one — FK on this row; a required relation cannot be 'set null'
author: field.relation({
to: (): AnyContentType => author,
required: true,
onDelete: 'restrict'
}),
// one-to-one — the same FK plus a UNIQUE constraint
seo: field.relation({ to: (): AnyContentType => seo_meta, unique: true }),
// many-to-many — join table content_article_tags, order preserved
tags: field.relation({ to: (): AnyContentType => tag, many: true }),
// one-to-many — the inverse of comment.article; stores nothing
comments: field.relationInverse({ of: (): AnyContentType => comment, field: 'article' })
}
});
// apps/server/src/content/collections/comment.ts — the owning side
export const comment = collection('comment', {
fields: {
body: field.text({ required: true }),
article: field.relation({ to: (): AnyContentType => article, required: true, onDelete: 'cascade' })
}
});The : AnyContentType annotation on a thunk breaks TypeScript’s circular
inference when two files import each other.
The four cardinalities
| Shape | Declaration | Storage |
|---|---|---|
| Many-to-one | field.relation({ to }) | <field>_id column |
| One-to-one | field.relation({ to, unique: true }) | <field>_id column + UNIQUE |
| Many-to-many | field.relation({ to, many: true }) | content_<type>_<field> join table |
| One-to-many | field.relationInverse({ of, field }) on the “one” side | None — reuses the owner’s |
field.relation
| Option | Default | Effect |
|---|---|---|
to | — | Thunk returning the target type. Required. |
many | false | Many-to-many via a join table. |
onDelete | 'set null', or 'cascade' when required | What happens to this row when the target is deleted. |
unique | false | One-to-one. Invalid with many. |
required | false | A link must be present (to publish, on a publishable type). |
syncAcrossLocales | true | Whether an edit in one locale reaches the others. |
onDelete
| Value | When the target row is deleted |
|---|---|
'set null' | The link is cleared. |
'cascade' | This row is deleted too. |
'restrict' | The delete is refused: 409, “still referenced by other entries”. |
- It is a foreign-key action: on a
paranoidtarget it fires on purge, not on soft delete. - Ignored on a many-relation — join rows go with either side.
required+'set null'fails at boot (aNOT NULLcolumn cannot be nulled):
Relation "article.author" is required but its onDelete is 'set null' — a NOT NULL foreign key cannot be nulled on delete. Use 'cascade' or 'restrict'.unique
A UNIQUE on <field>_id. Nullable-unique: any number of rows may have no
link; two rows may not claim one target. The service checks first so a clash
is a field-level 422, not a constraint-name 500. unique with many fails
at boot.
field.relationInverse
The read/write other side of a relation, declared on the type that does not own the storage.
| Option | Default | Effect |
|---|---|---|
of | — | Thunk returning the owning type. |
field | — | The relation field on of this side mirrors. |
many | true | Whether this side is to-many. |
- No column, no join table, no migration.
- Both sides edit the same rows, so they cannot drift.
- A broken pairing (missing field, not storage-owning, points elsewhere) fails at boot.
- The inverse of a single relation is read-only; the inverse of a
many-to-many can link and unlink. The schema says which: an inverse field
carries
inverse.writable(since 0.13.1), and the record editor lists a read-only one with no Add or Remove — “Linked from Articles — change it there.”
Join tables
tags on article → content_article_tags, order preserved. Re-export every
join table from the schema entry or drizzle-kit never creates it:
export const articleTagsJoinTable = joinTableOf(article, 'tags'); // throws if renamedReading links
Public reads return only a type’s own values — relation fields are not in
values. Ask for them explicitly.
curl 'https://cms.example.com/api/v1/content/article/0b9e…?relations=preview&relationFields=author,tags&relationLimit=5' \
-H 'Authorization: Bearer orthacms_…' query Article($id: ID!) {
article(id: $id) {
title
author { name handle } # single relation → the object, or null
tags(pageSize: 5) { # many / inverse → a page
total
items { name slug }
}
comments { total items { body } }
}
} {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "content_relations",
"arguments": { "typeName": "article", "id": "0b9e…", "field": "tags", "pageSize": 20 }
}
} REST response (trimmed):
{
"id": "0b9e…",
"status": "published",
"locale": "en",
"localeGroupId": "5c1d…",
"values": { "title": "Hello world" },
"relations": {
"author": {
"items": [{ "id": "a7f2…", "values": { "name": "Ada Lovelace", "handle": "ada-lovelace" } }],
"total": 1
},
"tags": {
"items": [{ "id": "9c4b…", "locale": "en", "values": { "name": "Design", "slug": "design" } }],
"total": 7
}
}
}totalcounts every visible link;relationLimit(default 20, max 100) only capsitems.- Page the rest:
GET /api/v1/content/article/:id/relations/tags?page=2&pageSize=20→{ "items": [...], "total": 7 }. - Only published, non-deleted targets appear or count.
- Naming a field that is not a relation, or whose target type the workspace cannot read, is a 400.
Writing links
A single relation is a value. A many-relation (or the inverse of one) takes a
delta in relations — ids you don’t mention are left alone.
curl -X PATCH https://cms.example.com/api/v1/content/article/0b9e… \
-H 'Authorization: Bearer orthacms_…' \
-H 'Content-Type: application/json' \
-d '{
"values": { "author": "a7f2…" },
"relations": {
"tags": { "link": ["9c4b…"], "unlink": ["1f2e…"], "order": ["9c4b…", "77aa…"] }
}
}' mutation {
updateArticle(
id: "0b9e…"
input: { author: "a7f2…" }
relations: { tags: { link: ["9c4b…"], unlink: ["1f2e…"] } }
) {
id
status
}
} {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "content_update",
"arguments": {
"typeName": "article",
"id": "0b9e…",
"values": { "author": "a7f2…" },
"relations": { "tags": { "link": ["9c4b…"] } }
}
}
} | Delta key | Applies to | Effect |
|---|---|---|
link | many / inverse-of-many | Append these ids. Re-linking is a no-op. |
unlink | many / inverse-of-many | Remove these ids. |
order | owning many only | Renumber the listed ids. |
set | owning single | The target id, or null to clear. Alternative to values. |
by | any | "id" (default) or "localeGroup": the ids are translation-group ids, resolved to the row in the source entry’s locale. Both sides must be localized. |
{
"relations": {
"tags": { "link": ["<tag group id>"], "by": "localeGroup" },
"seo": { "set": null }
}
}- Deltas apply in the same transaction as the save.
- The same single relation in both
valuesandrelationsis a 400. - Array keys on a single relation, or any delta on the inverse of a single relation, is a 400.
Relations across locales
On an i18n type a link belongs to the record by default: tagging the
English article tags the German one.
- You choose
syncAcrossLocales(defaulttrue).localized: trueon a relation meansfalse; setting both in contradiction fails at boot. - The target type decides how a synced link is stored:
| Behaviour | When | Stored |
|---|---|---|
shared | sync on, target not i18n | The same target id in every sibling |
mirrored | sync on, target i18n | The target’s group, resolved to each sibling’s locale |
none | sync off, an inverse, or a non-i18n owner | Nothing propagates |
The schema read reports it per field as relation.localeSync.
Links may not cross locales. Linking a German article to an English tag is a 422:
{
"message": "Entry validation failed",
"issues": [{
"field": "tags",
"message": "must reference a \"de\" entry — \"9c4b…\" is \"en\". Link the translation of that record in this entry’s locale."
}]
}A mirrored link whose target has no translation in a sibling’s locale is left unset on that sibling rather than failing the save — and filled in when that translation is created (since 0.11.0). A delta on the inverse side of a synced many-relation reaches the owner’s other languages the same way a save of the owner would.
One-to-one on a localized type
unique: true becomes a unique index on (<field>_id, locale): all language
rows of one article can share one SEO record; another article cannot claim it.
Shared workspaces
New in 0.7.0. A shared workspace — a library of authors, tags, legal copy — exposes its published records to other workspaces, read-only, to link to and render. See ADR-0019.
Set it up with two admin API calls:
# 1. The library flags itself shared (workspaces:update)
curl -X PATCH https://cms.example.com/api/workspaces/$LIBRARY_ID \
-b 'orthacms_session=…' -H 'Content-Type: application/json' \
-d '{ "isShared": true }'
# 2. A consumer takes a shared grant of one type from that library
curl -X POST https://cms.example.com/api/workspaces/$SITE_ID/content \
-b 'orthacms_session=…' -H 'Content-Type: application/json' \
-d '{ "slug": "author", "sourceWorkspaceId": "'$LIBRARY_ID'" }'GET /api/workspaces/:id/shared-sources lists the shared workspaces a
consumer can pick from, each with the types it offers. Revoke with
DELETE /api/workspaces/:id/content/author?source=<library id>.
Then link a library record like any other — by id:
{ "values": { "author": "<library author id>" } }What a consumer can do with a library record:
| Action | Result |
|---|---|
| Link to it from an owning relation | Allowed, if published, not deleted, and the source is shared and active |
| Link to it from the inverse side of a many-to-many | Refused — that would write into the library’s relation |
| Read it over the public API | The type’s list is the union of own + shared sources, published rows only |
| Update, publish, delete it | 404 (admin and REST); a 403 tool error naming the source on MCP |
| Create its type without an own grant | 403 — This workspace can only use "Authors" records from shared workspaces; it cannot create its own. |
- Hidden, not broken: a link to a record that stops being visible (unpublished, deleted, unshared, archived, grant revoked) disappears from every read. The row stays, so re-publishing restores it.
- Not transitive: a shared workspace exposes its own rows only.
- The library can check who links to a record before unpublishing it:
curl https://cms.example.com/api/content/author/a7f2…/usages \
-b 'orthacms_session=…' -H 'X-Workspace-Id: '$LIBRARY_ID
# { "items": [{ "workspaceId": "…", "workspaceName": "Travel site", "count": 12 }] }onDelete: 'restrict'still holds across workspaces: a library record a consumer links with it cannot be purged until the consumer unlinks it.- Reader segments apply to library records too, resolved in the consumer workspace.
- The admin list takes
?source=own|shared|all(defaultown) and?sourceWorkspaceId=; every item carriessource: { workspaceId, workspaceName }ornull. On MCP,content_listtakes the samesource.
A required relation to an unreachable type is not required
A workspace reaches a type when it holds an own grant for it, or a shared
grant whose source is still shared, active and holding the type. If it cannot
reach a relation’s target type, nobody there could pick a value — so that
relation’s required is waived in that workspace.
- Applies to the save, the single publish and the bulk publish alike.
- A value sent anyway is still validated.
- Exception: a required owning single relation on a non-publishable
type is a
NOT NULLcolumn and is never waived. - A target reachable only through a shared grant is not waived — the editor can pick a library record.
Relation targets are checked, and failures look alike
Before a write lands, every target must exist and be visible from this
workspace (own, or a published record of a shared source) — and, on a localized
target, be in the row’s locale. A missing target and an invisible one give the
same 422, must reference an existing entry, so ids cannot be enumerated.