Content modelling
Collections
defineCollection, the options it takes, drafts, indexes, and how the schema reaches the database.
A collection is a document type. defineCollection returns a plain object — nothing is registered
globally, so you export it and pass it to the runtime.
import { defineCollection, defineField } from '@forge-cms/core';
export const posts = defineCollection({
slug: 'posts',
drafts: true,
fields: {
title: defineField.text({ required: true, maxLength: 120 }),
slug: defineField.slug({ autoGenerate: true, sourceField: 'title' }),
excerpt: defineField.textarea(),
body: defineField.richtext(),
publishedAt: defineField.date({ withTime: true }),
author: defineField.relation({ collection: 'users' })
},
access: {
read: () => true,
create: ['admin', 'editor'],
update: ['admin', 'editor'],
delete: ['admin']
},
hooks: {
beforeChange: [({ data }) => ({ ...data, updatedBy: 'system' })]
}
});
Options
| Option | Type | Meaning |
|---|---|---|
slug |
string (required) |
Table name, URL segment, and the key every API call uses |
fields |
FieldMap (required) |
See Fields |
access |
CollectionAccess |
Per-operation rules. See Access control |
hooks |
CollectionHooks |
Nine lifecycle stages. See Hooks |
drafts |
boolean |
Adds a _status field and hides unpublished documents from the public |
upload |
boolean |
POST accepts multipart/form-data. See Uploads |
indexes |
CollectionIndex[] |
Compound indexes/unique constraints — see Indexes below |
Types come free
CollectionData<typeof posts> infers the document shape from the field definitions — including
nested group and array fields:
import type { CollectionData } from '@forge-cms/core';
type Post = CollectionData<typeof posts>;
// { title: string; slug: string; excerpt: string; body: RichTextContent;
// publishedAt: Date; author: string | string[] }
Note that this is the schema shape, not the API response shape: documents read back from the
database also carry id, created_at, updated_at, and _status on a drafts collection. The
first three (and an upload's _storageKey) are Forge-owned and not writable through the CMS API — see
Local API → System metadata. The same
definition types the Angular client without code generation: see Angular client → Typed client.
From definition to database
runtime.syncSchema() walks every registered collection and:
- creates the table if it does not exist (
generateCreateTableSql), withid,created_at,updated_at, plus_statuswhendrafts: true; - compares the declared fields against
PRAGMA table_infoand issuesALTER TABLE … ADD COLUMNfor anything new (generateAddColumnSql); - creates any declared indexes with
CREATE [UNIQUE] INDEX IF NOT EXISTS(generateIndexSql) — see Indexes below.
Schema sync never destroys data. Columns are never dropped or retyped, so renaming a field means
adding a new column, and removing one leaves the old column in place. Since 0.8.0, sync goes
further: a change that would need a data migration (a removed or renamed field, a type change, a new
required field on a table with rows) stops startup with a SchemaDriftError instead of being
half-applied. You apply it with a reviewed migration (on main, next release). See
Schema upgrades. Composite
fields (group, array, blocks) are stored as JSON in a TEXT column, which is why you cannot
query inside them yet.
Indexes
Two ways to index a field, both reflected in the generated SQL:
slug: defineField.slug({ index: true }); // a plain index
email: defineField.email({ unique: true }); // a unique index
For a constraint that spans more than one field — the common case is "this combination of fields
must be unique" — use the collection's indexes option instead:
export const catalogs = defineCollection({
slug: 'catalogs',
fields: {
project: defineField.relation({ collection: 'projects', required: true }),
locale: defineField.text({ required: true }),
namespace: defineField.text()
},
indexes: [{ fields: ['project', 'locale', 'namespace'], unique: true }]
});
This rejects a second catalogs document with the same (project, locale, namespace) triple, while
happily allowing (A, en, '') alongside (A, es, ''). Field order matters — it is the column
order of the generated index, so ['project', 'locale'] and ['locale', 'project'] are two different
indexes with two different (deterministic) names. Omit unique for a plain compound index with no
constraint, just a query optimization.
Index names are generated for you (idx_<collection>_<field1>_<field2>…) — there is no way to name
one yourself, so nothing here can collide with a name your own migration tooling might pick.
A conflict — whether from a single-field unique: true or a compound indexes entry — throws a
UniqueConstraintError from the Local API (err.code === 'UNIQUE_CONSTRAINT', err.collection,
err.fields) and comes back as HTTP 409 from the REST API. This is enforced identically on D1,
libSQL, and the in-memory adapter, so a compound-uniqueness bug fails in local dev/tests exactly
like it would in production, instead of only surfacing against a real database.
Indexes cannot reach inside a group/array/blocks field — those are JSON in a single TEXT
column, the same limitation querying them already has.
Drafts
Set drafts: true and the collection gains a system field _status: 'draft' | 'published':
- create defaults
_statusto'draft'; - anonymous reads only ever see
publisheddocuments — a draft 404s by id and is filtered from lists; - authenticated reads opt in with
status=draft,status=publishedorstatus=all(HTTP) or thestatusargument (Local API); - collections without
drafts: trueignore all of it.
// Editors' view: everything
await runtime.find({ collection: 'posts', status: 'all', user, overrideAccess: false });
// Public site: the default, published only
await runtime.find({ collection: 'posts', overrideAccess: false, user: null });
This is publication status, not version history — see Version history below.
Version history
Set versions: true and every write keeps a snapshot you can list and restore:
const posts = defineCollection({ slug: 'posts', versions: true, fields: { … } });
const history = await runtime.listVersions({ collection: 'posts', documentId: id }); // newest first
await runtime.restoreVersion({ collection: 'posts', versionId: history[1].id });
What you can rely on:
- The document and its snapshot are written together.
createwrites the document and version 1,updateandrestoreVersionwrite the change and the next version, as one atomic database write (DatabaseAdapter.atomicWrite()). If either part fails, neither is saved. File uploads are the exception: an upload's stored object is not part of that write. - A snapshot is the full content, not just the fields an update touched: every field the collection
declares (
nullwhen empty), plus_statuson a drafts collection. It never containsid,created_at,updated_ator the internal storage key, and a restore never changes them. - Two edits to the same document at once don't silently overwrite each other. If two updates
start from the same version, one saves and the other fails with
ConcurrentModificationError(HTTP409,CONCURRENT_MODIFICATION) and saves nothing. Forge does not retry it for you, because yourbefore*hooks may already have run. Reload the document and submit the change again. - Version numbers are unique per document (
1, 2, 3, …), enforced by a unique database index. - Restore runs the normal update pipeline: update access, field write rules (checked for the
fields the restore actually changes), validation and hooks, then writes one version labelled
Restored from version N. A snapshot from before a field became required fails validation. Old versions stay readable, but they are not automatically valid documents today. - History is kept indefinitely. Deleting a document does not delete its versions. Over HTTP the
history of a deleted document answers
404, and a restore of it answers404. You can't create a new document with the id of a deleted document that still has history. - Call
runtime.syncSchema()at startup (as in setup above): it creates the version-number index the conflict detection relies on. - A
before*hook must not callupdate()on the document being updated: that saves a version first, so the outer update fails withConcurrentModificationError. Changedatain the hook instead. runtime.createVersion()(Local API only) stores a snapshot you pass in yourself: no hooks, no access checks, stored exactly as given.versions: { autosave: true }is accepted but currently does nothing. There is no editor autosave.
Upgrading from an earlier release: syncSchema() adds the version-number index to existing
_versions_* tables. Two updates that ran at the same moment in earlier releases could leave two rows
with the same version number for one document. If your database has such rows, syncSchema() stops
with a message listing them. Forge never deletes or renumbers history for you: back up the database,
decide which rows to keep, fix them, then restart. Snapshots written by earlier releases hold only the
fields that update changed; restoring one applies only those fields.
Relation integrity
A top-level relation field chooses what happens to documents that reference a deleted document:
author: defineField.relation({ collection: 'authors', onDelete: 'cascade' }); // delete them too
editor: defineField.relation({ collection: 'authors', onDelete: 'set-null' }); // clear the reference
reviewer: defineField.relation({ collection: 'authors' }); // 'restrict' (default): refuse the delete
- One atomic delete.
delete()first works out everything a delete implies, using reads only. That covers cascades of cascades,set-nullupdates (single →null;many→ the id is removed from the array) andrestrictchecks. Next it runs everybeforeDelete/beforeChangehook and validation. Only then does it commit all the row changes in one database batch. A hook, validation or database failure anywhere in the graph changes nothing. After-hooks run once it has committed. - Final state decides. A
restrictreference blocks the delete only if the document holding it survives. A document deleted by the same cascade does not block. A survivingrequiredfield configuredset-nullrefuses the delete, because it cannot becomenull. - Concurrency. If a reference to a deleted document is created, or a dependent document is edited,
while the delete is in progress, the delete fails with
409 CONCURRENT_MODIFICATIONand nothing changes; reload and retry. - Bounded. One delete commits at most 25 database operations. That total counts cascaded deletes, set-null updates (plus one version snapshot each on a versioned collection) and one reference check per referring field. A larger graph is refused before any hook runs. It is never split into several commits; delete or detach dependents in smaller steps.
- Writes check targets.
create/update/updateGlobalDocumentrefuse a relation or upload value naming a document that does not exist (400 INVALID_INPUT). If the target is deleted while the write is in progress, the write fails with409. An update only checks the references it changes. A reference it re-sends unchanged must still be on the document when it saves: if a delete cleared it meanwhile, the save fails with409instead of putting the deleted id back. uploadfields and globals restrict. A document referenced by anuploadfield, or by any relation/upload field of a global, cannot be deleted while that reference exists.- Not supported, refused at startup: a
relation/uploadinsidegroup,arrayorblocks(the value lives in a JSON column Forge cannot query); a localizedrelation/upload; a relation to a collection that is not registered;onDeleteother thanrestricton a global. Also refused:cascade/set-nullonto a collection managed by the auth adapter (users are deleted only through the auth adapter). Lift such a reference to a top-level field, or keep the id in atext/jsonfield as an explicitly unchecked reference. - Users are restrict-only. Deleting a user through the auth adapter (
deleteUser) is refused while any relation/upload field or global still references it (UserMutationError, reason'referenced',409from the user routes). Change or remove those references first. Nothing is cleared or cascaded for you. The check and the delete commit together, so a reference written meanwhile makes the delete fail rather than dangle.ForgeCmsRuntimewires this automatically; a custom auth adapter that manages a referenced collection must implementsetManagedDeleteGuard, or the runtime refuses to start. - Dependent writes run as trusted consequences of the authorized delete (
overrideAccess: true), but with their normal validation and hooks. Uploaded files of cascaded upload documents are removed after the database commit; a storage failure is logged and does not undo the delete.
Globals
A global is a singleton document, such as site settings. You define it with defineGlobal and pass it
to ForgeCmsRuntime({ globals }):
const site = defineGlobal({
slug: 'site',
drafts: true,
locales: ['en', 'es'],
access: { read: () => true, update: ({ user }) => user?.role === 'admin' },
fields: {
title: defineField.text({ required: true }),
theme: defineField.text({ defaultValue: 'light' }),
tagline: defineField.text({ localized: true })
}
});
- Writes. The first
updateGlobalDocumentcreates the row; later ones are partial, like a collection update. Omitted fields keep their values, and defaults and the draft status apply only to the first write. - Concurrent first writes. If two first writes race, exactly one commits and the other gets
409. - Access. A global has
readandupdateaccess only. An access rule that returns a query must match the stored row. Such a rule cannot authorize the first write, because there is no row yet; seed it from trusted server code. - Refused at startup:
access.create/access.deleteand delete hooks on a global. A global is never created throughcreatenor deleted, so they could never apply.
Localization
- Declaring it. Declare
localeson a collection or global, and marktext/textareafieldslocalized: true. Each such field stores one value per locale. - Writing. Write with
locale(Local API) or?locale=(HTTP). Only that locale changes; the others are kept. - Concurrent edits. On a global, two simultaneous per-locale edits (each written with
locale) cannot silently drop one: the later gets409. A write withoutlocalethat sends the whole map replaces it, last writer wins. - Reading. A read with
localeresolves each localized field, falling back to the language and then to the first declared locale. A read without it returns the whole{ en, es }map. - Storage. On libSQL and D1 the map is stored as JSON in a TEXT column. Filtering or sorting on a localized field is not supported.
- Refused at startup:
- a localized field without
locales; - a localized field of any other kind;
- a localized field nested in a
group/array/blocks; - a localized
relation/upload.
- a localized field without
Registering collections
const runtime = new ForgeCmsRuntime({
collections: [posts, media, withAuthFields(users)],
adapters: { database, auth, storage },
env
});
runtime.init();
await runtime.syncSchema();
withAuthFields() from @forge-cms/auth merges the columns the auth adapter writes
(passwordHash, role, …) into your users collection, so schema generation covers them. Skipping
it is how you get a table users has no column named passwordHash error in production.
A note on the users collection
The collection your AuthAdapter uses is a normal collection for reading: you define it, you can add
fields to it, and it shows up in the admin. UsersCollectionAuthAdapter defaults to the slug users.
It is not a normal collection for writing: while an auth adapter manages it, generic
create/update/delete — the Local API (trusted or not) and /api/v1/users — are refused with
AUTH_MANAGED_COLLECTION, and users are created, changed and deleted through the auth adapter
(createUser, updateUser, deleteUser, signup). See Browser auth and
Adapters for the auth side.