Content model

Collections and proposals

Collections and globals are definitions stored in D1, changed through proposals an admin approves.


A collection holds many documents; a global holds exactly one. Both are described by a definition stored in cms_collections. There is no table per collection: documents are JSON in cms_documents, so adding a field never needs a migration.

{
  "slug": "posts",
  "kind": "collection",
  "label": "Posts",
  "titleField": "title",
  "drafts": true,
  "locales": { "locales": ["en", "ja"], "defaultLocale": "en" },
  "access": { "list": ["authenticated"], "read": ["authenticated"], "create": ["editor"],
              "update": ["owner"], "delete": ["owner"], "publish": [], "public": ["public"] },
  "hooks": [{ "type": "slugify", "from": "title", "to": "slug" },
            { "type": "stampOwner", "field": "authorId" }],
  "fields": [
    { "name": "title", "type": "text", "label": "Title", "required": true },
    { "name": "slug", "type": "text", "label": "Slug", "unique": true },
    { "name": "body", "type": "richtext", "label": "Body" },
    { "name": "authorId", "type": "text", "label": "Author", "hidden": true }
  ]
}

Slugs and field names are camelCase (/^[a-z][a-zA-Z0-9]*$/). Names starting with _ and the built-in names (pages, layouts, menus, site, theme) are reserved. The order of fields is the order in the admin form.

Hooks

Hooks are declared by kind; the kinds are fixed in code.

  • slugify: fill to with a URL slug made from from when it is empty

  • stampOwner: record the creating user's id in a field

  • setOnCreate: force a field to a value on create, whatever the client sent (a public form's status)

  • custom: a hook the host registered by name in createCMS({ hooks })

Changing the structure

Definitions change only through proposals (POST /_cms/proposals, the MCP tool propose_schema_change, or the schema editor in the admin). A proposal is a list of operations:

  • createCollection, updateCollection, deleteCollection (deletes its documents too)

  • addField, updateField, moveField

  • renameField: also rewrites the key in every stored live and draft copy

  • removeField: non-destructive; the values stay in the JSON but are hidden

POST /api/_cms/proposals
{
  "title": "Add a summary to posts",
  "operations": [
    { "op": "addField", "collection": "posts", "after": "title",
      "field": { "name": "summary", "type": "text", "label": "Summary",
                 "options": { "multiline": true, "maxLength": 300 } } }
  ]
}

On creation the engine validates the resulting definitions and measures the impact: how many documents are affected and how many stored values would no longer fit their field type. Admins and editors may propose; only admins approve (POST /_cms/proposals/:id/_approve) or reject (_reject). Approval measures again and applies the operations.

Collections defined in code (the built-in ones, and any passed as createCMS({ collections })) are marked system and cannot be changed by proposals.