Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Version history

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

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

Documents 0.13.1 Updated Edit this page Report a problem

On this page

Every save appends an immutable snapshot of the whole entry. Nothing to switch on — every content type has history.

Reading history

curl 'https://cms.example.com/api/content/article/0b9e…/revisions?page=1&pageSize=20' \
  -b 'orthacms_session=…' -H 'X-Workspace-Id: 7d0c…'
{
    "items": [
        { "id": "…", "number": 5, "status": "draft", "isPublished": false, "isLatest": true,
          "createdAt": "2026-09-29T10:12:00.000Z", "authorId": "…" },
        { "id": "…", "number": 4, "status": "published", "isPublished": true, "isLatest": false,
          "createdAt": "2026-09-28T08:40:00.000Z", "publishedAt": "2026-09-28T08:41:03.000Z", "authorId": "…" }
    ],
    "total": 5
}

One version, with its snapshot and resolved relation references:

curl https://cms.example.com/api/content/article/0b9e…/revisions/4 \
  -b 'orthacms_session=…' -H 'X-Workspace-Id: 7d0c…'
{
    "id": "…",
    "number": 4,
    "status": "published",
    "isPublished": true,
    "isLatest": false,
    "createdAt": "2026-09-28T08:40:00.000Z",
    "snapshot": {
        "values": { "title": "Hello world", "author": "a7f2…" },
        "relations": { "tags": ["9c4b…", "77aa…"] }
    },
    "relationRefs": {
        "tags": [
            { "id": "9c4b…", "title": "Design", "slug": "design" },
            { "id": "77aa…", "title": "Research", "slug": "research" }
        ]
    },
    "relationTotals": { "tags": 2 }
}

History is admin API only. Newest first; pageSize defaults to 20, max 100.

Endpoints

RoutePermissionReturns
GET /api/content/:type/:id/revisionscontent:readThe timeline, { items, total }.
GET /api/content/:type/:id/revisions/:numbercontent:readOne snapshot, with resolved relation and media refs.
POST /api/content/:type/:id/revisions/:number/restorecontent:updateRe-applies it as a new version. Returns the entry.
POST /api/content/:type/:id/revisions/:number/publishcontent:publishMakes that version live. Returns the entry.

What a snapshot holds

One table, content_entry_revisions, shared by every type. A snapshot is { values, relations, extra? }:

  • values — the whole values bag: scalars, single-relation FKs, media values.
  • relations — the full ordered link set of every join-backed relation, so a reorder is a recorded change.
  • extra — state other plugins keep about the entry, such as the segments access lists. A restore hands it back. A version written before such a plugin was installed has no extra, meaning “unknown”, not “none”.

Revisions are per locale — each translation has its own timeline. Every read is keyed on (content_type, entry_id, workspace_id).

When a revision is written

  • Inside the save’s transaction, atomically with the row and its links.
  • The entry’s advisory lock serialises concurrent saves, so version numbers never collide.
  • The live row still edits in place; history only appends.

Version status

StatusMeaning
draftWritten by a save, never published.
publishedThe live version. At most one per entry.
supersededWas live; another version replaced it.
  • Publishing the entry marks its latest revision published and demotes the previous one to superseded, in the same transaction.
  • Saving a published entry makes it Modified: the row returns to draft, the published revision keeps its mark.
  • The public API serves that published revision while the entry is Modified — REST, GraphQL and MCP alike (since 0.7.0). Before 0.7.0 a Modified entry vanished from public reads until it was published again.
  • Unpublishing returns the live version to draft.

Restoring versus publishing a version

RestorePublish a version
Route…/revisions/:number/restore…/revisions/:number/publish
New version?Yes — restoring v2 appends v6 equal to v2No — v2 itself is marked live
Live rowUpdated through the normal save pathRe-applied from v2 if it is older
Goes live?No, it is a draft editYes, after the publish gate

Publishing a version re-runs the publish gate. On a 422 the content stays re-applied as a draft — recoverable, not half-done.

After publishing an old version, latest is not live

Publish v2 while v5 exists: v5 is the latest, never-published draft; v2 is live. isLatest and isPublished point at different versions until the next save appends one equal to the live row.

Diffing in the admin

The entry’s History tab lists versions newest first, grouped by day, marking Live and the Current draft, with the record’s activity events between them. A selected version is compared with the current draft beside the list; previewing one compares it field by field with the current version, and unchanged fields fold behind “Show n unchanged”. Empty values do not show as "" → null changes, and link sets compare in order.

A relation target that has since been soft-deleted, or is no longer visible from this workspace, is flagged missing instead of leaking its title.

No retention policy, and no pruning

Every save keeps a full snapshot and nothing expires them. A permanent delete leaves them in place; they go only with the workspace. On wide types with large rich-text bodies, content_entry_revisions is the table that grows fastest.