Features Apograph CMS on GitHub

Version history

Content plugin @apograph/content-server@apograph/content-admin

Every save is snapshotted, history is append-only, and restoring differs from publishing an old version.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

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:

StatusMeaning
DraftWritten by a save, never published.
PublishedThe version that is currently live. At most one per entry.
SupersededWas 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

RoutePermissionReturns
GET /api/content/:type/:id/revisionscontent:readThe timeline, newest first.
GET /api/content/:type/:id/revisions/:numbercontent:readOne snapshot, with resolved relation refs.
POST /api/content/:type/:id/revisions/:number/restorecontent:updateRe-applies it as a new version.
POST /api/content/:type/:id/revisions/:number/publishcontent:publishMakes 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.