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 editorEdit cms/schema.json directly, then npm run types:gen
Build a UI that edits the schemaThe octocms/schema library + octocms/admin/actions/schema server actions (this page)
Programmatically validate a ConfigvalidateConfig(cfg, Object.keys(cfg.collections)) from octocms/cli/lib/validateConfig
Regenerate every schema-driven artifact in memoryregenerateAll(cfg) from octocms/cli/lib/codegen
Diff two schema snapshotsdiffSchema(prev, next, options?) from octocms/schema
Predict which entries a change will affectpreviewSchemaChange(next) from octocms/admin/actions
Commit a schema change with content migrationssaveSchema(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):

LayerWherePurpose
Public schema authoringoctocms/schema/Pure types, the field-format registry, the diff function, and the entry-migration helpers. Zero imports from octocms/admin/.
Codegenoctocms/cli/lib/codegen.ts, schemaDocs.ts, agentDocs.tsPure functions that turn a Config into TypeScript files and Markdown docs. regenerateAll(cfg) returns every file as a path → content map.
Server actionsoctocms/admin/actions/schema.tsgetSchema, previewSchemaChange, saveSchema. The only layer that touches GitHub or the filesystem.

Library reference — octocms/schema#

typescript
import {
  // 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:

typescript
type 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[].

typescript
const 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:

KindPayload
collection-addedcollection
collection-removedcollection
collection-renamedfrom, to
collection-hasMany-changedcollection, from, to
field-addedcollection, field, format
field-removedcollection, field, format
field-renamedcollection, from, to
field-format-changedcollection, 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).

typescript
const 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 changeBehaviour
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 ↔ stringBest-effort coerce; drop with warning if it fails
markdown ↔ richtextCompanion-file rename (.md.mdx)
Anything → markdown / richtextDrop inline value, warn (caller must promote to companion file)
markdown / richtext → anythingDelete companion file, warn (content cannot round-trip)
reference, image, json, select, conditional ↔ anythingDrop with warning

The bias is conservative: when in doubt, drop the value rather than producing subtly broken content.

Codegen — regenerateAll(cfg)#

typescript
import { 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:

typescript
import {
  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.

typescript
type 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:

  1. 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).
  2. Cross-collection — entries whose reference fields target a removed or renamed collection. The scan calls the same migrateReferences helper that saveSchema uses, 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:

  1. Validate the new config (throws on failure → returned as an error result).
  2. Diff against the current schema.
  3. Migrate every affected entry via migrateEntry (collection-scoped: field renames/removals, format coercion, companion file ops). Then sweep every other entry in the repo through migrateReferences to rewrite reference keys into renamed collections and prune orphaned keys to removed collections.
  4. Regenerate every schema-driven artifact via regenerateAll.
  5. Commit all of the above as a single batch:
    • Production: one GitHub commit on the active feature branch via commitMultipleFilesToGitHub (uses git/createTree + git/createCommit + git/updateRef).
    • Dev: filesystem writes (renames, deletes, and overwrites in sequence — atomic batching is unnecessary on local disk).
  6. 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.

typescript
const 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:

typescript
import { 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-branch cookie.
  • 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 (or npm 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:

ScriptFiles written
npm run types:gencms/__generated__/*
npm run docs:gendocs/generated/schema.md (+ api-routes.md, which is filesystem-driven and not part of regenerateAll)
npm run agent-docs:genoctocms/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 .md file (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 revert on the schema commit.

Testing#