Features Apograph CMS on GitHub

Publishing and deletion

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

The states an entry moves through, and what publishing actually validates before it lets a row go live.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

Publishing is opt-in per content type. A type without publishable: true has no publish state at all — every row is simply live.

export const article = collection('article', {
    publishable: true,
    paranoid: true,
    fields: { title: field.text({ required: true }) }
});

The states

StateWhat it is
Draftstatus = 'draft', published_at null. Never been live.
Publishedstatus = 'published', published_at set. Live.
Modifiedstatus = 'draft', published_at set. Live content exists, and there are unpublished edits on top of it.

“Modified” is not a fourth column value — it is the combination of a draft status and a non-null published_at. Saving an edit to a live entry moves it back to draft but keeps published_at, which is what makes the distinction between “never published” and “published, then edited” expressible.

If you are querying this yourself: status eq draft on its own counts never-published drafts as edits. Modified is status eq draft and publishedAt is not null.

Required means required to publish

On a publishable type, required: true does not stop a save. The column stays nullable, and the rule is checked when the entry is published.

That is what lets an author save an incomplete draft and come back to it. The same declaration on a non-publishable type produces a real NOT NULL column, because there is no draft stage in which the value could legitimately be missing.

The publish gate

Publishing re-checks the stored row against the type’s rules — every required field present, every present value valid. The admin mirrors the same predicate to enable or disable its Publish button, so an author is not offered a button that will fail.

The gate covers the values bag. A required link-managed relation — an owning many-to-many, or the inverse of one — cannot be judged from values, because its links never travel there, so those are enforced separately by counting links. The two together are the full server-side precondition.

A failed publish is a 422 naming the fields, and the entry stays where it was.

When a content type is protected

The gate above is the second of three checks. Where a workspace has written a protection rule for the type, publication additionally needs a number of approvals on the entry’s current revision — by default from somebody other than whoever wrote that revision.

It is a separate question from this one, and deliberately so: the publish gate owns whether the entry is complete, protection owns whether this person may ship it now. Approvals never make an incomplete entry publishable, and an administrator’s bypass passes protection only — the 422 above still stands.

A blocked publish is a 409 naming what was required and what had been given. With no rule for the type, nothing on this page changes and the publish path runs exactly as it did before the plugin existed.

Publishing endpoints

RouteEffect
POST /api/content/:type/:id/publishRuns the gate and goes live.
POST /api/content/:type/:id/unpublishBack to draft.
POST /api/content/:type/bulk/publishPublish many.
POST /api/content/:type/bulk/publish/previewWhat would happen, without doing it.
POST /api/content/:type/bulk/unpublishUnpublish many.

The bulk preview exists because a bulk publish across a mixed selection is otherwise a guess: some entries will pass the gate and some will not, and an editor deserves to know which before pressing the button rather than after.

Publishing also promotes the entry’s version history: the relevant revision becomes published and any previously published one is demoted to superseded, so at most one version of an entry is live at a time.

Deletion

With paranoid: true, deleting is a soft delete: deleted_at is stamped and the row stays. Without it, delete removes the row.

RouteEffect
DELETE /api/content/:type/:idSoft delete — tombstones the row.
POST /api/content/:type/:id/restoreClears deleted_at.
DELETE /api/content/:type/:id/permanentRemoves the row for good.
POST /api/content/:type/bulk/deleteSoft delete many.
POST /api/content/:type/bulk/restoreRestore many.
POST /api/content/:type/bulk/purgePermanently delete many.

Permanent deletion is permanent

/permanent and bulk/purge remove the row. The entry’s revision history goes with it, and a relation with onDelete: 'cascade' pointing at it will take its own rows down too. There is no undo and no trash beyond the soft-delete stage.

On a localized type, deletion is per row — that is, per language. Deleting the German translation of an article does not delete the English one. The group survives as long as any sibling does.

What a soft-deleted row is invisible to

A tombstoned row is excluded from list reads, from the public API entirely, and from relation targets. It still occupies its (locale_group_id, locale) slot — the unique index on that pair is partial, WHERE deleted_at IS NULL, so a trashed row does not block a replacement translation from being created.

The same partial-index treatment applies to one-to-one relation uniqueness, so a trashed row cannot hold a relation target hostage.

No scheduled publishing

An entry goes live when somebody or something publishes it. There is no publish-at date, no scheduler and no worker process. If you need timed publishing, it has to come from outside — a cron job hitting the publish endpoint with an API token is the honest workaround.