Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Relations

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

Four cardinalities out of two storage forms, the inverse side that stores nothing, and links across shared workspaces.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

Two storage forms, four cardinalities:

  • Single relation — a <field>_id foreign-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

ShapeDeclarationStorage
Many-to-onefield.relation({ to })<field>_id column
One-to-onefield.relation({ to, unique: true })<field>_id column + UNIQUE
Many-to-manyfield.relation({ to, many: true })content_<type>_<field> join table
One-to-manyfield.relationInverse({ of, field }) on the “one” sideNone — reuses the owner’s

field.relation

OptionDefaultEffect
to—Thunk returning the target type. Required.
manyfalseMany-to-many via a join table.
onDelete'set null', or 'cascade' when requiredWhat happens to this row when the target is deleted.
uniquefalseOne-to-one. Invalid with many.
requiredfalseA link must be present (to publish, on a publishable type).
syncAcrossLocalestrueWhether an edit in one locale reaches the others.

onDelete

ValueWhen 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 paranoid target 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 (a NOT NULL column 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.

OptionDefaultEffect
of—Thunk returning the owning type.
field—The relation field on of this side mirrors.
manytrueWhether 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 renamed

Public reads return only a type’s own values — relation fields are not in values. Ask for them explicitly.

Example language
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
        }
    }
}
  • total counts every visible link; relationLimit (default 20, max 100) only caps items.
  • 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.

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.

Example language
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 keyApplies toEffect
linkmany / inverse-of-manyAppend these ids. Re-linking is a no-op.
unlinkmany / inverse-of-manyRemove these ids.
orderowning many onlyRenumber the listed ids.
setowning singleThe target id, or null to clear. Alternative to values.
byany"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 values and relations is 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 (default true). localized: true on a relation means false; setting both in contradiction fails at boot.
  • The target type decides how a synced link is stored:
BehaviourWhenStored
sharedsync on, target not i18nThe same target id in every sibling
mirroredsync on, target i18nThe target’s group, resolved to each sibling’s locale
nonesync off, an inverse, or a non-i18n ownerNothing 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:

ActionResult
Link to it from an owning relationAllowed, if published, not deleted, and the source is shared and active
Link to it from the inverse side of a many-to-manyRefused — that would write into the library’s relation
Read it over the public APIThe type’s list is the union of own + shared sources, published rows only
Update, publish, delete it404 (admin and REST); a 403 tool error naming the source on MCP
Create its type without an own grant403 — 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 (default own) and ?sourceWorkspaceId=; every item carries source: { workspaceId, workspaceName } or null. On MCP, content_list takes the same source.

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 NULL column 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.