Every save keeps an immutable snapshot of the whole entry, so an entry has a browsable history and can be put back the way it was.
There is nothing to switch on. Version history applies to every content type.
How it is stored
One table, content_entry_revisions, shared by every content type — the same
generic mechanism as the entry writer itself.
A snapshot is a jsonb document of { values, relations }: the field values
bag — scalars, localized and shared alike, plus single-relation foreign keys —
together with the full ordered link sets of every join-backed relation. So
reordering related articles is a change the history records.
Revisions are keyed per locale. The entry_id is the live row, so each
translation has its own timeline rather than a group sharing one.
Because every content type shares one table, every read is keyed on
(content_type, entry_id, workspace_id) — all three. The content_type leg is
not decoration: the routes address an entry as :typeName/:id, and without it
any registered type name would serve any entry’s history, including its full
snapshot.
When a revision is written
A revision is appended inside the same transaction as the save, so a version commits atomically with the row and its relation writes. The entry’s advisory lock serialises concurrent savers, so two simultaneous saves cannot allocate the same version number.
The live row still edits in place. History is additive: nothing is rewritten.
Version status
A revision is born a draft. Publishing the entry promotes its history too, in
the same transaction as the status write on the row:
| Status | Meaning |
|---|---|
| Draft | Written by a save, never published. |
| Published | The version that is currently live. At most one per entry. |
| Superseded | Was live; another version replaced it. |
Because a save moves the entry to draft but leaves the published revision published, an editor can accumulate draft versions while the previously published one stays live — and then publish any of them.
Restoring versus publishing a version
These are different operations and the difference matters.
Restore (POST .../revisions/:number/restore) re-applies a snapshot through
the normal update path. That appends a new version: restoring v2 yields a
fresh v6 equal to v2. History is never rewritten.
Publish a version (POST .../revisions/:number/publish) marks that version
itself live, demoting the previously live one to superseded. If it is an
earlier version, its content is first re-applied to the live row so the live
document matches what is published — but no new version is recorded, so
publishing v2 leaves the timeline at its existing length with v2 marked live,
rather than minting a copy of v2 on every publish.
Publishing a version re-runs the publish gate. A 422 leaves the content re-applied as a draft, which is a recoverable state rather than a half-finished one.
After publishing an old version, latest is not live
Publish v2 while v5 exists, and the newest version is a draft that was never
published while v2 is the live one. isLatest and “equals the live row” come
apart in that window. The next save appends a version equal to the live row
again and the two line up.
Diffing
The admin’s history dialog renders a field-level diff between two versions.
Empty values collapse rather than showing as changes from "" to null, and
link sets compare order-sensitively — a reordered list is a real difference.
The single-revision detail is enriched with resolved relation references, so the
preview lists the actual linked records rather than raw uuids. A target that has
since been soft-deleted or belongs to another workspace is flagged missing
rather than having its title leaked.
Endpoints
| Route | Permission | Returns |
|---|---|---|
GET /api/content/:type/:id/revisions | content:read | The timeline, newest first. |
GET /api/content/:type/:id/revisions/:number | content:read | One snapshot, with resolved relation refs. |
POST /api/content/:type/:id/revisions/:number/restore | content:update | Re-applies it as a new version. |
POST /api/content/:type/:id/revisions/:number/publish | content:publish | Makes that version live. |
No retention policy, and no pruning
Every save keeps a full snapshot of the whole entry, for as long as the entry
exists, and there is no configuration to cap or expire them. On a wide type with
large rich-text bodies and a busy editorial team, content_entry_revisions is
the table that will grow fastest. Nothing prunes it for you.