Browse documentation

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:

  1. creates the table if it does not exist (generateCreateTableSql), with id, created_at, updated_at, plus _status when drafts: true;
  2. compares the declared fields against PRAGMA table_info and issues ALTER TABLE … ADD COLUMN for anything new (generateAddColumnSql);
  3. 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 _status to 'draft';
  • anonymous reads only ever see published documents — a draft 404s by id and is filtered from lists;
  • authenticated reads opt in with status=draft, status=published or status=all (HTTP) or the status argument (Local API);
  • collections without drafts: true ignore 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. create writes the document and version 1, update and restoreVersion write 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 (null when empty), plus _status on a drafts collection. It never contains id, created_at, updated_at or 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 (HTTP 409, CONCURRENT_MODIFICATION) and saves nothing. Forge does not retry it for you, because your before* 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 answers 404. 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 call update() on the document being updated: that saves a version first, so the outer update fails with ConcurrentModificationError. Change data in 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-null updates (single → null; many → the id is removed from the array) and restrict checks. Next it runs every beforeDelete/beforeChange hook 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 restrict reference blocks the delete only if the document holding it survives. A document deleted by the same cascade does not block. A surviving required field configured set-null refuses the delete, because it cannot become null.
  • 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_MODIFICATION and 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/updateGlobalDocument refuse 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 with 409. 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 with 409 instead of putting the deleted id back.
  • upload fields and globals restrict. A document referenced by an upload field, or by any relation/upload field of a global, cannot be deleted while that reference exists.
  • Not supported, refused at startup: a relation/upload inside group, array or blocks (the value lives in a JSON column Forge cannot query); a localized relation/upload; a relation to a collection that is not registered; onDelete other than restrict on a global. Also refused: cascade/set-null onto 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 a text/json field 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', 409 from 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. ForgeCmsRuntime wires this automatically; a custom auth adapter that manages a referenced collection must implement setManagedDeleteGuard, 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 updateGlobalDocument creates 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 read and update access 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.delete and delete hooks on a global. A global is never created through create nor deleted, so they could never apply.

Localization

  • Declaring it. Declare locales on a collection or global, and mark text/textarea fields localized: 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 gets 409. A write without locale that sends the whole map replaces it, last writer wins.
  • Reading. A read with locale resolves 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.

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.