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

Schema builder plugin

Schema builder plugin @orthacms/schema-builder-server@orthacms/schema-builder-admin

The Content Model page's plugin — registration, configuration, routes, permission and event.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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.

PackageRole
@orthacms/schema-builder-domainThe framework-free kernel: the schema document, the diff, the verdicts, the code generator, the manifest renderer orthacms content sync also uses
@orthacms/schema-builder-serverPlugin. The document, plan, apply and operation routes; drizzle-kit and prettier as child processes; the lock
@orthacms/schema-builder-adminPlugin. 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 group

It 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:

VariableDefaultWhat it does
SCHEMA_BUILDERfalseAsk for editing. Still refused in production and without a source tree
SCHEMA_BUILDER_ROOTapps/serverThe server app’s folder, resolved against the working directory the server starts in
SCHEMA_BUILDER_RESTARTwatchmanual 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:

OptionDefaultWhat it does
projectRoot—The server app’s folder. Absolute — the factory refuses a relative one
contentDirsrc/contentWhere the type files and the manifest live; must stay inside the root
migrationsDirmigrationsWhere drizzle-kit writes
migrationsTable__drizzle_migrations_contentThe tracking table the content migrations use
generateTimeoutMs60000How 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.

RoutePermissionBodyAnswers
GET /api/schema-builder/documentcontent:read—200 — the model, its fingerprint, the process’s boot id and the editing capabilities
POST /api/schema-builder/planschema:manage + editable{ document, baseFingerprint }200 — the plan: changes and verdicts, files, SQL. A blocked plan is answered, not refused
POST /api/schema-builder/applyschema:manage + editable{ document, baseFingerprint, migrationName, confirmed[] }202 { operationId, bootId }
GET /api/schema-builder/operations/:idschema:manage—200 — the apply’s status; 404 unknown
  • Editable means SCHEMA_BUILDER=true, a non-production NODE_ENV, the source tree on disk and a generated manifest. Otherwise plan and apply answer 403 schema-builder.disabled, whatever the caller’s role.
  • baseFingerprint is the 16-hex fingerprint the draft started from; a different one is 409 schema-builder.stale.
  • confirmed lists the id of every destructive change — at most 500.
  • migrationName must match ^[a-z][a-z0-9_]{0,59}$: it ends up in a file name.
  • An operation goes running (step generate, migrate, publish) → succeeded or failed. One recorded by a process that since restarted reads interrupted.
  • After succeeded, poll the document route until bootId changes: a 200 from 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? }:

codeStatusWhen
schema-builder.disabled403Production, flag off, no source tree, hand-written manifest
schema-builder.invalid-document400The document is malformed
schema-builder.stale409The fingerprint is not the running model’s
schema-builder.busy409Another apply holds the lock
schema-builder.invalid422A schema rule fails
schema-builder.not-owned422The draft changes a hand-written type
schema-builder.blocked422Apply of a blocked plan
schema-builder.unconfirmed422A destructive change without its own confirmation
schema-builder.migration-ambiguous422drizzle-kit wanted to ask a question anyway
schema-builder.migration-failed500drizzle-kit failed or timed out
schema-builder.publish-failed500Writing src/content/ failed after the migration

Permission

KeyHeld byOpens
content:readevery roleThe document route and the read-only page
schema:manageadministrators onlyPlan, 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 dev or orthacms dev restarts the server on the new src/. The plugin never restarts anything itself.