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: filltowith a URL slug made fromfromwhen it is emptystampOwner: record the creating user's id in a fieldsetOnCreate: 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 increateCMS({ 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,moveFieldrenameField: also rewrites the key in every stored live and draft copyremoveField: 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.