Show navigationHide navigation
Schema editing API
OctoCMS treats cms/schema.json as the source of truth for your data model.
You can edit it by hand and run npm run types:gen, or you can use the
schema editing API described here — a pure-function library plus three
server actions that validate a proposed schema, predict its impact on
existing content, and commit the change atomically.
This page documents the architecture, the public API, and how to drive it programmatically. The visual Content Model UI (Phase 3+) is built on top of this same API and shares no extra surface.
When to use it#
| You want to… | Use |
|---|---|
| Hand-edit the schema in your editor | Edit cms/schema.json directly, then npm run types:gen |
| Build a UI that edits the schema | The octocms/schema library + octocms/admin/actions/schema server actions (this page) |
| Programmatically validate a Config | validateConfig(cfg, Object.keys(cfg.collections)) from octocms/cli/lib/validateConfig |
| Regenerate every schema-driven artifact in memory | regenerateAll(cfg) from octocms/cli/lib/codegen |
| Diff two schema snapshots | diffSchema(prev, next, options?) from octocms/schema |
| Predict which entries a change will affect | previewSchemaChange(next) from octocms/admin/actions |
| Commit a schema change with content migrations | saveSchema(next) from octocms/admin/actions |
Architecture#
┌─────────────────────────────────────────────────────────────────────────┐ │ cms/schema.json ← source of truth (plain JSON, hand-editable) │ └──────────────────────────────────┬──────────────────────────────────────┘ │ regenerateAll(cfg) ▼ ┌─────────────────────────────────────────────────────────────────────────┐ │ cms/__generated__/{schema.ts, types.ts, enums.ts, content.d.ts, │ │ index.ts, query.ts, configInit.ts} │ │ docs/generated/schema.md │ │ octocms/docs/{index.md, overview.md, schema.md} │ └─────────────────────────────────────────────────────────────────────────┘
The library is split across three layers (each can be imported standalone):
| Layer | Where | Purpose |
|---|---|---|
| Public schema authoring | octocms/schema/ | Pure types, the field-format registry, the diff function, and the entry-migration helpers. Zero imports from octocms/admin/. |
| Codegen | octocms/cli/lib/codegen.ts, schemaDocs.ts, agentDocs.ts | Pure functions that turn a Config into TypeScript files and Markdown docs. regenerateAll(cfg) returns every file as a path → content map. |
| Server actions | octocms/admin/actions/schema.ts | getSchema, previewSchemaChange, saveSchema. The only layer that touches GitHub or the filesystem. |
Library reference — octocms/schema#
typescriptimport { // Types type Config, type Collection, type CollectionField, type FieldFormat, type FieldFormatMeta, type SchemaOptionField, // Field-format registry FIELD_FORMATS, FIELD_FORMAT_META, getFieldFormatMeta, // Diff diffSchema, type SchemaChange, type DiffOptions, // Migration migrateEntry, groupChangesByCollection, type ContentEntry, type EntryMigrationResult, type CompanionFileOp, type EntryFileOp, type MigrateEntryOptions, } from 'octocms/schema';
Field-format registry#
FIELD_FORMAT_META is a map keyed by the 15 formats (string, text,
markdown, boolean, reference, image, number, datetime, json,
slug, select, url, color, conditional, richtext). Each entry
captures everything the visual editor's "Add Field" dialog needs:
typescripttype FieldFormatMeta = { format: FieldFormat; label: string; // Human label, e.g. 'Short text' description: string; // One-sentence summary optionFields: readonly SchemaOptionField[]; // Per-format option controls storageNote: string; // How the value is stored on disk placeholderValue: string; // JSON placeholder for example entries }; type SchemaOptionField = { key: string; // Property name on CollectionField (e.g. 'min') label: string; type: 'string' | 'number' | 'boolean' | 'enum' | 'collections' | 'selectOptions' | 'stringList'; enumValues?: readonly string[]; description?: string; required?: boolean; defaultValue?: unknown; };
The registry is the single source of truth for both the UI and the agent docs — replace hand-coded format switches with a registry lookup whenever possible.
diffSchema(prev, next, options?)#
Compares two Config snapshots and produces a flat SchemaChange[].
typescriptconst changes = diffSchema(prev, next, { // Renames cannot be inferred from add+remove pairs alone — pass them // explicitly when the UI knows about them. Keys are *prev* names; values // are *next* names. collectionRenames: { post: 'article' }, fieldRenames: { article: { body: 'content' } }, });
Change kinds:
| Kind | Payload |
|---|---|
collection-added | collection |
collection-removed | collection |
collection-renamed | from, to |
collection-hasMany-changed | collection, from, to |
field-added | collection, field, format |
field-removed | collection, field, format |
field-renamed | collection, from, to |
field-format-changed | collection, field, from, to |
migrateEntry(entry, changes, options?)#
Pure function that applies the schema diff to a single entry. The caller is
responsible for the resulting filesystem ops (saveSchema does this for
you).
typescriptconst result = migrateEntry( { sys: { id: 'p1', type: 'post', status: 'merged' }, fields: { title: 'Hi', count: '42' }, path: 'cms/content/post/post-p1.json', }, changes, { prevConfig: prev }, // optional — needed for collection-rename companion moves ); // result.entry — the migrated entry, or null if it should be deleted // result.fileOps — moves/deletes for the entry JSON file // result.companionOps — moves/deletes for companion .md / .mdx files // result.warnings — strings to surface to the user before commit
Coercion behaviour:
| Format change | Behaviour |
|---|---|
Within text-like (string, text, slug, url, color) | Pass-through (the new format may still reject the value at validation time) |
string ↔ number, string ↔ datetime, boolean ↔ string | Best-effort coerce; drop with warning if it fails |
markdown ↔ richtext | Companion-file rename (.md ↔ .mdx) |
Anything → markdown / richtext | Drop inline value, warn (caller must promote to companion file) |
markdown / richtext → anything | Delete companion file, warn (content cannot round-trip) |
reference, image, json, select, conditional ↔ anything | Drop with warning |
The bias is conservative: when in doubt, drop the value rather than producing subtly broken content.
Codegen — regenerateAll(cfg)#
typescriptimport { regenerateAll } from 'octocms/cli/lib/codegen'; const { files } = regenerateAll(cfg); // files is a Record<string, string> with these keys: // cms/schema.json // cms/__generated__/schema.ts // cms/__generated__/types.ts // cms/__generated__/enums.ts // cms/__generated__/content.d.ts // cms/__generated__/index.ts // cms/__generated__/query.ts // cms/__generated__/configInit.ts // docs/generated/schema.md // octocms/docs/index.md // octocms/docs/overview.md // octocms/docs/schema.md
regenerateAll is deterministic (same input → byte-identical output)
and pure — no filesystem I/O, no shell-out, no devDependencies.
This is what makes it safe to run from a Vercel server action: the result
is a map that can be committed to GitHub via a single tree commit.
It throws if validateConfig rejects the input, so callers should always
catch.
Server actions — octocms/admin/actions#
Three actions are exposed via the existing barrel; import them directly or
through octocms/admin/actions:
typescriptimport { getSchema, previewSchemaChange, saveSchema, } from 'octocms/admin/actions';
getSchema(): Promise<Config>#
Reads cms/schema.json from the active feature branch (production) or the
local working tree (dev).
previewSchemaChange(next, options?): Promise<PreviewSchemaResult>#
Runs validation and an impact scan. Read-only — never writes anything.
typescripttype PreviewSchemaResult = { valid: boolean; errors: string[]; // populated when valid = false changes: SchemaChange[]; // empty when validation failed impact: { path: string; // e.g. cms/content/post/post-p1.json type: string; id: string; title: string; // entry-title field, falls back to the id reasons: string[]; // why this entry is affected warnings: string[]; // migration warnings (coercion failure, dropped companion content) dataLoss: boolean; // true when the change discards content }[]; };
The scan covers two impact axes:
- Direct hits — entries inside collections that were renamed, removed, had fields renamed/removed, or had a field's format changed. Entries whose value at the affected field is null/empty are skipped (no material change).
- Cross-collection — entries whose
referencefields target a removed or renamed collection. The scan calls the samemigrateReferenceshelper thatsaveSchemauses, so the impact reason ("3 references will be rewritten" / "1 orphaned reference will be removed") matches what actually lands in the commit.
dataLoss: true whenever the change discards content (deleted entries,
dropped field values, pruned references, coercion failures). The visual
editor surfaces this as a colored badge per row.
Use previewSchemaChange to drive a confirmation dialog before letting
the user save destructive changes.
saveSchema(next, options?): Promise<ActionResult>#
The full write path. Steps:
- Validate the new config (throws on failure → returned as an error result).
- Diff against the current schema.
- Migrate every affected entry via
migrateEntry(collection-scoped: field renames/removals, format coercion, companion file ops). Then sweep every other entry in the repo throughmigrateReferencesto rewrite reference keys into renamed collections and prune orphaned keys to removed collections. - Regenerate every schema-driven artifact via
regenerateAll. - Commit all of the above as a single batch:
- Production: one GitHub commit on the active feature branch via
commitMultipleFilesToGitHub(usesgit/createTree+git/createCommit+git/updateRef). - Dev: filesystem writes (renames, deletes, and overwrites in sequence — atomic batching is unnecessary on local disk).
- Production: one GitHub commit on the active feature branch via
- Bust caches by calling
buildJsons('').
Production writes are gated by assertFeatureBranchForWritesIfRequired —
the same guard that protects saveFile/newFile/removeFile. The CMS
will refuse to write directly to git.baseBranch.
typescriptconst result = await saveSchema(next, { collectionRenames: { post: 'article' }, fieldRenames: { article: { body: 'content' } }, message: 'CMS: rename post → article', // optional commit message }); if (!result.success) { toast.error(result.error); }
Multi-file commit helper#
Production schema saves use a new GitHub helper added in Phase 2:
typescriptimport { commitMultipleFilesToGitHub, type GitHubBatchChange } from 'octocms/admin/github'; await commitMultipleFilesToGitHub( [ { kind: 'upsert-text', path: 'cms/schema.json', content: '...' }, { kind: 'upsert-text', path: 'cms/__generated__/types.ts', content: '...' }, { kind: 'delete', path: 'cms/content/legacy/legacy-x.json' }, ], 'CMS: schema update', 'cms/edits', // active branch );
The helper rejects empty diffs (GitHub returns a no-op tree, which is always a bug here).
Branch / commit semantics#
Schema edits ride the same feature-branch workflow as content edits:
- The active branch is read from the
cms-active-branchcookie. - Every change is committed on that branch.
- "Publish" works the same way as for content — switch the published-branch
pointer (
cms/pointers/<build-id>.json; see Multi-deploy). - A schema change therefore triggers a Vercel rebuild; the new types take effect on the next request.
Hand editing remains a first-class workflow#
Nothing about the API changes how cms/schema.json works as a file. You
can:
- Edit it by hand in your editor.
- Run
npm run types:gen(ornpm run docs:gen/agent-docs:gen) to regenerate. - Commit and push as you would any other file.
The visual editor and the hand-edit flow share the same codegen
(regenerateAll) and the same validator (validateConfig), so output
matches exactly.
Relationship to the codegen scripts#
The three npm run *:gen scripts are now thin wrappers around
regenerateAll:
| Script | Files written |
|---|---|
npm run types:gen | cms/__generated__/* |
npm run docs:gen | docs/generated/schema.md (+ api-routes.md, which is filesystem-driven and not part of regenerateAll) |
npm run agent-docs:gen | octocms/docs/* |
npm run *:check variants run the corresponding generator and then
git diff --exit-code — they fail CI if hand-edits drifted from the
generated output.
Limitations & non-goals#
- No automatic rename detection. Add+remove pairs are inherently
ambiguous; the UI must pass renames explicitly via
DiffOptions. - No data migration for
markdown/richtext↔ inline. Promoting an inline string to a companion.mdfile (or pulling companion content back inline) requires reading the file from disk; the pure migration layer cannot do that. The migration warns and drops; a future iteration can add a richer "migration plan" object that the server action fulfils. - No PR creation. Schema changes commit on the active feature branch; opening / labelling the PR uses the existing branch UI (Phase 3 of the visual editor plan).
- No undo. Use
git reverton the schema commit.
Testing#
octocms/schema/diffSchema.test.ts— every change kind, with and without rename hints.octocms/schema/migrateContent.test.ts— coercion, companion file ops, warnings.octocms/schema/fieldFormats.test.ts— registry covers all 15 formats; per-format option keys are unique.octocms/cli/lib/regenerateAll.test.ts— file map shape, determinism, validateConfig integration.octocms/admin/actions/schema.test.ts— server-action behaviour forgetSchema,previewSchemaChange,saveSchema(dev + prod paths).