The plugin behind the admin’s Content Model page. It reads the running
content registry and, in development, plans and applies a change as TypeScript
under src/content/ plus a drizzle-kit migration. It owns no tables: its
state is your source tree and a gitignored scratch folder.
How to use it is in the schema builder guide. This page is the plugin’s surface.
| Package | Role |
|---|---|
@orthacms/schema-builder-domain | The framework-free kernel: the schema document, the diff, the verdicts, the code generator, the manifest renderer orthacms content sync also uses |
@orthacms/schema-builder-server | Plugin. The document, plan, apply and operation routes; drizzle-kit and prettier as child processes; the lock |
@orthacms/schema-builder-admin | Plugin. The /content-model page: the type list, each type’s fields, the kind picker and field panel, the one-page review, the apply progress and the grant card |
Install and register
create-orthacms-app installs and registers both halves in every app, with
editing off, since 0.8.0. For an older app, see
upgrading.
npm install @orthacms/schema-builder-server @orthacms/schema-builder-admin// apps/server/src/plugins.ts
import { SchemaBuilderPlugin } from '@orthacms/schema-builder-server';
// after content — it reads content's registry. Read-only unless
// SCHEMA_BUILDER=true outside production; it owns no schema.
SchemaBuilderPlugin(config.plugins.schemaBuilder);// apps/admin/src/plugins.ts
import { SchemaBuilderPlugin } from '@orthacms/schema-builder-admin';
SchemaBuilderPlugin(); // the Content Model entry in the Directory groupIt owns no tables, so its position decides nothing about migrations. The composition root registers it straight after GraphQL (after saved views when GraphQL is off), with the other content consumers.
Adding it to an existing app
Editing needs the generated manifest. If your src/content/index.ts was
written by hand, move each type to collections/<name>.ts or
pages/<name>.ts, exporting a type named <name>, and run
orthacms content sync once. Until then the page is read-only with
“hand-written manifest” as the reason. It also needs drizzle.config.ts and
the migrations descriptor on ContentPlugin — see
migrations.
Configuration
apps/server/config/schema-builder.ts, assembled under plugins.schemaBuilder:
| Variable | Default | What it does |
|---|---|---|
SCHEMA_BUILDER | false | Ask for editing. Still refused in production and without a source tree |
SCHEMA_BUILDER_ROOT | apps/server | The server app’s folder, resolved against the working directory the server starts in |
SCHEMA_BUILDER_RESTART | watch | manual when nothing restarts the process on a change to src/ — the page then asks you to restart it |
// apps/server/config/schema-builder.ts
import { resolve } from 'node:path';
import type { SchemaBuilderPluginConfig } from '@orthacms/schema-builder-server';
import { isProduction, readEnv, readFlag } from '@orthacms/utils-server';
export function schemaBuilderConfig(): SchemaBuilderPluginConfig {
const restart = readEnv('SCHEMA_BUILDER_RESTART');
return {
enabled: readFlag('SCHEMA_BUILDER', false),
production: isProduction(),
projectRoot: resolve(readEnv('SCHEMA_BUILDER_ROOT') ?? 'apps/server'),
restart: restart === 'manual' ? 'manual' : 'watch'
};
}The plugin config also takes, in code:
| Option | Default | What it does |
|---|---|---|
projectRoot | — | The server app’s folder. Absolute — the factory refuses a relative one |
contentDir | src/content | Where the type files and the manifest live; must stay inside the root |
migrationsDir | migrations | Where drizzle-kit writes |
migrationsTable | __drizzle_migrations_content | The tracking table the content migrations use |
generateTimeoutMs | 60000 | How long one drizzle-kit run may take before it is killed |
prettier and drizzle-kit are resolved from your app first, so its versions write its files. Neither run needs a database: generating only diffs snapshots.
Routes
All under /api, global — no X-Workspace-Id. Session cookie, plus RBAC.
| Route | Permission | Body | Answers |
|---|---|---|---|
GET /api/schema-builder/document | content:read | — | 200 — the model, its fingerprint, the process’s boot id and the editing capabilities |
POST /api/schema-builder/plan | schema:manage + editable | { document, baseFingerprint } | 200 — the plan: changes and verdicts, files, SQL. A blocked plan is answered, not refused |
POST /api/schema-builder/apply | schema:manage + editable | { document, baseFingerprint, migrationName, confirmed[] } | 202 { operationId, bootId } |
GET /api/schema-builder/operations/:id | schema:manage | — | 200 — the apply’s status; 404 unknown |
- Editable means
SCHEMA_BUILDER=true, a non-productionNODE_ENV, the source tree on disk and a generated manifest. Otherwise plan and apply answer403 schema-builder.disabled, whatever the caller’s role. baseFingerprintis the 16-hex fingerprint the draft started from; a different one is409 schema-builder.stale.confirmedlists the id of every destructive change — at most 500.migrationNamemust match^[a-z][a-z0-9_]{0,59}$: it ends up in a file name.- An operation goes
running(stepgenerate,migrate,publish) →succeededorfailed. One recorded by a process that since restarted readsinterrupted. - After
succeeded, poll the document route untilbootIdchanges: a200from the old process is not the restart.
curl -b cookies.txt http://localhost:3000/api/schema-builder/document{
"document": {
"version": 1,
"types": [
{ "name": "recipe", "kind": "collection", "label": "Recipes", "origin": "builder", "…": "…" }
]
},
"fingerprint": "9f2c41a07be3d655",
"bootId": "…",
"capabilities": { "editable": false, "reason": "disabled", "restart": "watch" }
}Errors carry { statusCode, code, message, details? }:
code | Status | When |
|---|---|---|
schema-builder.disabled | 403 | Production, flag off, no source tree, hand-written manifest |
schema-builder.invalid-document | 400 | The document is malformed |
schema-builder.stale | 409 | The fingerprint is not the running model’s |
schema-builder.busy | 409 | Another apply holds the lock |
schema-builder.invalid | 422 | A schema rule fails |
schema-builder.not-owned | 422 | The draft changes a hand-written type |
schema-builder.blocked | 422 | Apply of a blocked plan |
schema-builder.unconfirmed | 422 | A destructive change without its own confirmation |
schema-builder.migration-ambiguous | 422 | drizzle-kit wanted to ask a question anyway |
schema-builder.migration-failed | 500 | drizzle-kit failed or timed out |
schema-builder.publish-failed | 500 | Writing src/content/ failed after the migration |
Permission
| Key | Held by | Opens |
|---|---|---|
content:read | every role | The document route and the read-only page |
schema:manage | administrators only | Plan, apply, the operation log, and the editor |
schema:manage came with the schema builder. No API-token scope carries it:
an apply changes every workspace at once, runs DDL and writes the repository.
And holding it is not enough — on a server that may not edit, it opens nothing.
See permissions.
Event
One schema.applied per successful apply, through the outbox — aggregate
schema, id the operation id. changes holds the applied change ids,
migrations the generated migration files (none for a code-only change) and
files what was written under src/content/:
{
"operationId": "…",
"actorId": "…",
"changes": ["…"],
"migrations": ["…"],
"files": ["…"]
}The activity log does not render it yet, and webhooks do not offer it — it is not an entry event.
What it relies on
- Content — the running
ContentTypeRegistry, and the row counts behind the verdicts. - Workspaces — grants, to block removing a type in use; the grant card
on the applied page calls
POST /api/workspaces/:id/content. - Database — drizzle’s migrator for the host’s content migrations.
- The dev watcher —
npm run devororthacms devrestarts the server on the newsrc/. The plugin never restarts anything itself.