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

Publishing and deletion

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

The states an entry moves through, what the API serves in each, and what publishing validates before a row goes live.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

Publishing is opt-in per type. Without publishable: true there is no publish state — every row is live.

export const article = collection('article', {
    publishable: true,
    paranoid: true,
    fields: {
        title: field.text({ required: true }),
        tags: field.relation({ to: () => tag, many: true, required: true })
    }
});

The states

StateRowPublic API serves
Draftstatus = 'draft', published_at nullNothing
Publishedstatus = 'published', published_at setThe row
Modifiedstatus = 'draft', published_at setThe published version, with status: "published"

Modified is not a stored value: it is a draft that has a published_at. Saving a live entry moves it back to draft and keeps published_at.

To find Modified entries in the admin list, filter status eq draft and publishedAt is not null — status eq draft alone also matches never-published drafts.

What readers see while an entry is Modified

Since 0.7.0 the public API keeps serving the version that was last published — its values, its links, status: "published" — until the next publish. The unpublished edits never leak, and a ?filter= or ?search= cannot match them.

ReadSees
REST /api/v1, GraphQL, MCP — default (published)The published version
Same, ?status=draft / ?status=any (a full token)The working copy
Admin APIThe working copy

The published version comes from the entry’s version history; updatedAt on the public read is when that version was written.

Required means required to publish

On a publishable type required: true does not block a save — the column stays nullable and the rule runs at publish. On a non-publishable type it is NOT NULL.

The publish gate

Publishing re-checks the stored row:

  1. Every value rule — required fields present, every present value valid.
  2. Every required link-managed relation (an owning many-to-many, or the inverse of one) has at least one link. These links never travel in values, so they are counted separately.

A required relation whose target type this workspace cannot reach is skipped — see relations.

Example language
curl -X POST https://cms.example.com/api/v1/content/article/0b9e…/publish \
-H 'Authorization: Bearer orthacms_…'
mutation {
  publishArticle(id: "0b9e…") {
      id
      status
      publishedAt
  }
}
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
      "name": "content_publish",
      "arguments": { "typeName": "article", "id": "0b9e…" }
  }
}

Publishing over the public API needs a full token. A failed gate is a 422 and the entry stays where it was:

{
    "message": "Entry validation failed",
    "issues": [
        { "field": "title", "message": "is required" },
        { "field": "tags", "message": "is required" }
    ]
}

A successful publish stamps published_at afresh — it records the latest publish, not the first.

Bulk publishing

Up to 100 ids per call. Preview first, then publish:

curl -X POST https://cms.example.com/api/content/article/bulk/publish/preview \
  -b 'orthacms_session=…' -H 'X-Workspace-Id: 7d0c…' \
  -H 'Content-Type: application/json' \
  -d '{ "ids": ["0b9e…", "4a11…"] }'
{
    "items": [
        {
            "id": "0b9e…", "title": "Hello world", "status": "draft", "verdict": "publishable",
            "issues": [],
            "checks": [
                { "field": "title", "label": "title", "ok": true },
                { "field": "tags", "label": "tags", "ok": true }
            ]
        },
        {
            "id": "4a11…", "title": "Draft idea", "status": "draft", "verdict": "blocked",
            "issues": [{ "field": "tags", "message": "is required" }],
            "checks": [
                { "field": "title", "label": "title", "ok": true },
                { "field": "tags", "label": "tags", "ok": false, "message": "is required" }
            ]
        }
    ]
}

checks lists every required field (and any field with an issue); label is the field’s admin.label, else its name.

POST …/bulk/publish with the same body publishes what passes and reports the rest:

{ "published": ["0b9e…"], "skipped": [{ "id": "4a11…", "reason": "blocked" }] }

Verdicts: publishable, already-published, blocked, not-found, guard-refused. Since 0.7.0 bulk publish checks required many-relations exactly like a single publish — before, an entry with no links could slip through a bulk publish.

When a type is protected

With a protection rule on the type, publishing also needs approvals on the entry’s current revision. It is a separate check from the gate: approvals never make an incomplete entry publishable.

A blocked publish is a 409:

{
    "statusCode": 409,
    "code": "protection.insufficient_approvals",
    "message": "Publishing \"article\" needs 2 approval(s) on the current version; it has 1.",
    "required": 2,
    "given": 1,
    "stale": 0,
    "bypassable": true
}
  • Someone with protection:manage may send { "bypass": true } as the admin publish body, where the rule allows it. Anyone else: 403 protection.bypass_refused.
  • An API token cannot be reviewed: its publish on a protected type is a 409 protection.token_refused unless the rule allows token publishing.
  • Bulk publish never bypasses; refused ids are skipped as guard-refused.

Publishing endpoints

Every route needs content:publish.

RouteEffect
POST /api/content/:type/:id/publishRuns the gate, goes live. Body { "bypass": true } optional.
POST /api/content/:type/:id/unpublishBack to draft, clearing published_at.
POST /api/content/:type/bulk/publish/previewVerdicts only; writes nothing.
POST /api/content/:type/bulk/publishPublishes what passes.
POST /api/content/:type/bulk/unpublishUnpublishes many.
POST /api/v1/content/:type/:id/publish, /unpublishPublic API, full token. Also by group/:localeGroupId.
POST /api/v1/content/:type/bulk/publish, /bulk/unpublishPublic API bulk.

Publishing marks the relevant revision published and demotes the previous one to superseded — at most one live version per entry. Unpublishing returns it to draft; the entry leaves public reads immediately.

Deletion

With paranoid: true delete is a soft delete (deleted_at stamped). Without it, the row is removed. Every route needs content:delete.

RouteEffect
DELETE /api/content/:type/:idSoft delete; hard delete on a non-paranoid type. 204.
POST /api/content/:type/:id/restoreClears deleted_at.
DELETE /api/content/:type/:id/permanentRemoves a trashed row for good.
POST /api/content/:type/bulk/deleteDelete many, soft or hard as above.
POST /api/content/:type/bulk/restoreRestore many.
POST /api/content/:type/bulk/purgePermanently delete many trashed rows.
DELETE /api/v1/content/:type/:idPublic API delete, full token. 204.
  • Restore and purge exist only on a paranoid type — elsewhere a 400.
  • Restoring a translation whose (locale_group_id, locale) slot was refilled meanwhile is a 409.
  • onDelete fires when a row leaves the table — on a paranoid type, at purge. A 'restrict' refusal is a 409, “still referenced by other entries”.
  • On a localized type deletion is per row: deleting the German translation leaves the English one.

Permanent deletion is permanent

/permanent and bulk/purge remove the row, and onDelete: 'cascade' relations take their rows with it. The version history is not removed: its snapshots stay in content_entry_revisions until the workspace is deleted.

A trashed row is excluded from list reads, the public API, relation previews and pickers. The (locale_group_id, locale) and one-to-one unique indexes are partial (WHERE deleted_at IS NULL), so a trashed row blocks neither a replacement translation nor a relation target.

No scheduled publishing

There is no publish-at date. For timed publishing, call the publish endpoint from a cron job with a full API token — on a type that is not protected, or whose rule allows token publishing.