Drafts and publishing

Opt-in draft/publish workflow with version history. Save unpublished edits, restore prior versions, and gate publish via RBAC.

Mark an entity with versions: true and the engine adds a complete drafts-and-publishing pipeline: every save is recorded as a version row, drafts layer on top of the published state without affecting public reads, and a single FK pointer tracks the pending draft and the published marker.


          import { entity, text } from "@cms/config";

const posts = entity("posts", {
  versions: true,
  fields: [text("title", { required: true }), text("body")],
});
        

The storage model is two FK columns on canonical (pending_draft_version_id, published_version_id) plus a shared _versions table. Restoring a version validates its stored data against the current schema — if the data no longer parses (e.g. a field type changed), the restore is refused. The engine never migrates stored data across schema changes.

State derivation

Every response with versions enabled carries a synthesised _status field:

| published_at | pending_draft_version_id | _status | |---|---|---| | NULL | (any) | "draft" | | NOT NULL | NULL | "published" | | NOT NULL | NOT NULL | "modified" |

"modified" is the "edit a published doc without affecting live" state: canonical stays at the published data, and a draft row sits on top via the FK. Public reads see canonical; admin reads with ?draft=true see canonical merged with the draft row.

How it maps to URLs

| Action | URL | Permission | |---|---|---| | Read published canonical | GET /api/:entity/:id | entity.read | | Read editorial view (canonical + pending draft if any) | GET /api/:entity/:id?draft=true | entity.versions.read | | Save draft (don't publish) | PUT /api/:entity/:id?draft=true | entity.versions.create | | Publish | PUT /api/:entity/:id | entity.update | | Discard pending draft | DELETE /api/:entity/:id?draft=true | entity.versions.discard | | Unpublish | POST /api/:entity/:id/unpublish | entity.update + entity.versions.create | | List history | GET /api/:entity/:id/versions | entity.versions.read | | Read one history row | GET /api/:entity/:id/versions/:vid | entity.versions.read | | Restore a version | POST /api/:entity/:id/versions/:vid | entity.update + entity.versions.read | | Hard delete the doc | DELETE /api/:entity/:id | entity.delete |

History is treated as an audit log: there is no per-version delete (no UI, no API). Rows are removed only by retention pruning (versions.limit) or by cascade when the canonical row is hard-deleted. The versions.discard permission gates the draft-discard endpoint — not history-row deletion.

For globals, the same endpoints exist relative to /api/:global (no :id segment). Hard-delete is not available for globals; use DELETE /?draft=true to discard the draft or POST /unpublish to take it off-air.

Typed SDK

@cms/client ships typed methods for every endpoint above. Reads keep a draft flag as a view selector (so a preview cookie can flip the whole client into draft mode via client.config({ draft: true }) or ClientOptions.draft). State transitions live on dedicated namespaces, so the call site reads as the intent.


          import { createClient } from "@cms/client";

const client = createClient({ url: "http://localhost:4000" });

// Reads — `draft: true` selects the editorial view. Drafts are fully valid,
// so the return type is the same `T` either way; only `_status` differs.
const published = await client.get<Post>("posts", id);
const editorial = await client.get<Post>("posts", id, { draft: true });

// Writes — namespaces match the lifecycle step.
await client.create<Post>("posts", { title: "WIP" });
await client.drafts.save<Post>("posts", id, { title: "Edited" });
await client.update<Post>("posts", id, fullBody);       // publish
await client.unpublish<Post>("posts", id);
await client.drafts.discard("posts", id);

// History.
const { data: rows } = await client.versions.list<Post>("posts", id);
await client.versions.restore<Post>("posts", id, versionId);

// Globals — same surface, no id arg. Pass `locale` on translatable globals.
await client.drafts.save<Settings>("site-settings", { siteName: "WIP" }, { locale: "en" });
await client.versions.list<Settings>("site-settings");
        

The client-level draft default is overridden by a per-call value (including false to force canonical on a single read).

Lifecycle by example


          # 1. Create — lands as draft with publishedAt NULL. The body must contain
#    every required field; drafts are fully valid.
curl -X POST /api/posts -d '{"title": "WIP"}'
# → { "data": { "id": "abc", "title": "WIP", "_status": "draft", ... } }

# 2. Save more drafts. Each save creates a new version row; canonical mirrors
#    the latest version while unpublished. PATCH semantics — only changed
#    fields need to be in the body; the server merges onto the prior draft
#    and strict-validates the merged result.
curl -X PUT /api/posts/abc?draft=true -d '{"title": "WIP v2"}'
curl -X PUT /api/posts/abc?draft=true -d '{"body": "intro paragraph"}'
# Pending draft now: { title: "WIP v2", body: "intro paragraph" }.

# 3. Publish. Strict-validates the merged state; sets publishedAt; the new
#    version row becomes the "published marker" (canonical.published_version_id).
curl -X PUT /api/posts/abc -d '{"title": "Final"}'
# → { "data": { "_status": "published", "publishedAt": "...", ... } }

# 4. Edit live without affecting the public-visible row. Canonical stays
#    frozen; a new version row sits on top via pending_draft_version_id.
curl -X PUT /api/posts/abc?draft=true -d '{"title": "Final (edited)"}'

# 5. View the pending edit (admin only — needs versions.read).
curl /api/posts/abc?draft=true
# → { "data": { "title": "Final (edited)", "_status": "modified", "_draftCreatedAt": "..." } }

# 6. Publish the pending edit. The pending draft becomes the new published
#    marker; FK clears.
curl -X PUT /api/posts/abc -d '{}'  # empty body — publishes what's pending

# 7. Or discard the pending edit.
curl -X DELETE /api/posts/abc?draft=true
# Canonical untouched; pending draft row(s) deleted.

# 8. Unpublish — keeps data, hides from public.
curl -X POST /api/posts/abc/unpublish
# publishedAt cleared, published_version_id cleared, _status becomes "draft".

# 9. Restore an old version. Creates a new version row with the chosen data,
#    sets pending_draft_version_id to it. Publication state unchanged.
curl -X GET /api/posts/abc/versions
curl -X POST /api/posts/abc/versions/v123
        

Drafts are PATCH-merged and strict-valid

Every PUT ?draft=true accepts a partial body and merges onto the prior pending draft (or canonical when no draft exists). The merged result must pass strict validation — drafts are fully valid documents that simply aren't published yet. So:


          # Canonical published with { title: "A", body: "old" }.
curl -X PUT /api/posts/abc?draft=true -d '{"title": "B"}'
# Pending draft now: { title: "B", body: "old" }.    ← merged + strict-valid.
curl -X PUT /api/posts/abc?draft=true -d '{"body": "new"}'
# Pending draft now: { title: "B", body: "new" }.    ← title preserved.
        

Required fields are enforced at every write — POST, PUT, PUT?draft=true, and restore all run the same strict validation. The DB-layer backstop is plain NOT NULL on required columns; a raw-SQL write that nulls a required column is rejected by Postgres.

Trying to create or save a draft with a required field missing returns 400 VALIDATION_ERROR with per-field details. There's no "save partial then fill in later" state — fill the required fields, then save. The versions feature is for the unpublished-but-valid workflow (work-in-progress entries that aren't ready to go live), not for half-typed drafts.

PATCH semantics are shallow per top-level field

The merge is JS spread, not a deep merge. Top-level scalar fields behave intuitively. Composite fields replace wholesale:

| Patch | Existing | Result | |---|---|---| | {title: "B"} | {title: "A", body: "X"} | {title: "B", body: "X"} | | {tags: ["a"]} | {tags: ["a", "b", "c"]} | {tags: ["a"]}b, c dropped | | {seo: {metaTitle: "X"}} | {seo: {metaTitle, metaDescription}} | {seo: {metaTitle: "X"}}metaDescription dropped | | {author: {id, _entity}} | (single relation) | Whole relation ref replaces |

For array, object, richText, union, and single relations, send the full composite value. There is no null-as-delete or per-key merge. If you want to update a nested field, read the current state first and merge client-side.

Multi-relations (relation(..., {multiple: true})) live in a junction table, not the canonical row. The first draft save on a published document must include any required multi-relation field — the merge base derived from canonical doesn't carry it.

Upgrading from the partial-drafts release

Earlier versions of the engine modelled required fields as <entity>_<field>_required_when_published CHECK constraints instead of column-level NOT NULL. The upgrade has two parts: a schema migration (CHECK → NOT NULL) and a data audit (existing nulls must be scrubbed first, otherwise the migration aborts).

1. Audit your data. For every entity with versions enabled, list rows where any required column is currently NULL:


          SELECT id, pending_draft_version_id, <required_col_1>, <required_col_2>, ...
  FROM "<entity_table>"
  WHERE <required_col_1> IS NULL
     OR <required_col_2> IS NULL;
        

These are the partial drafts the new contract forbids. For each, either:

  • Fill it via the admin — opening the doc and saving any change will populate required fields and persist a strict-valid row, OR
  • Discard the pending draft so the row reverts to its last-published state:

          -- For published docs with an incomplete pending draft: detach the FK and
-- delete the post-publish version rows so the doc reverts to canonical.
WITH bad AS (
  SELECT id FROM "<entity_table>"
   WHERE <required_col_1> IS NULL
     OR <required_col_2> IS NULL
)
UPDATE "<entity_table>" SET pending_draft_version_id = NULL
  WHERE id IN (SELECT id FROM bad);
        

For never-published docs (the canonical row itself is incomplete), delete them or fill them via the admin — there's no "revert" target.

2. Generate and apply the migration.


          pnpm cms migration generate strict_drafts
        

The generator flags ALTER COLUMN SET NOT NULL as destructive (added in this release) — review the printed statements, confirm your audit is clean, then re-run with --accept-data-loss to write the migration. Then:


          pnpm cms migration apply
        

If apply fails with null value in column "<field>" violates not-null constraint, the entire migration transaction rolls back cleanly — your schema stays on the old version. Re-run step 1 against the row from the error message and retry.

3. After upgrading, every POST / PUT?draft=true strict-validates the merged result. Admin forms surface field-level errors via the existing 400 → ClientError.details path, no UI changes needed.

Lifecycle hooks

Every transition fires both a specific hook AND the catch-all afterUpdate (when canonical's user data changed).

| Event | Specific hook(s) | Catch-all beforeUpdate/afterUpdate fires? | |---|---|---| | POST /:entity (create) | beforeCreate, afterCreate | No (separate event) | | PUT /:id?draft=true on unpublished | beforeDraftSave, afterDraftSave | Yes (canonical data changes) | | PUT /:id?draft=true on published | beforeDraftSave, afterDraftSave | No (only FK moves) | | PUT /:id (publish) | beforePublish, afterPublish | Yes | | POST /:id/unpublish | beforeUnpublish, afterUnpublish | No (only published_at clears) | | POST /:id/versions/:vid (restore on unpublished) | beforeRestore, afterRestore | Yes | | POST /:id/versions/:vid (restore on published) | beforeRestore, afterRestore | No (only FK moves) | | DELETE /:id?draft=true (discard) | beforeDiscardDraft, afterDiscardDraft | No | | DELETE /:id (hard delete) | beforeDelete, afterDelete | No |


          const posts = entity("posts", {
  versions: true,
  fields: [text("title", { required: true })],
  hooks: {
    // Fires on publish (first publish + subsequent updates while published).
    afterPublish: async (post) => {
      await fetch(process.env.SITE_REBUILD_HOOK, { method: "POST" });
    },
    // Fires on every draft save (pre- or post-publish).
    afterDraftSave: async (post, ctx) => {
      await notifyEditor(ctx.user, post);
    },
    // Fires when the doc goes off-air.
    afterUnpublish: async (post) => {
      await cdn.purge(`/posts/${post.id}`);
    },
  },
});
        

The before-variants can transform the data (return the new shape) or throw to abort. The after-variants are fire-and-forget — errors are logged, the request succeeds.

The drafter role pattern

The versions.read / versions.create / versions.discard sub-permissions let you grant draft-editing rights without canonical update rights. An editor who can save drafts but not publish:


          role("drafter", {
  permissions: {
    posts: {
      read: true,
      create: true,
      update: false,   // ← can't publish (PUT /:id needs entity.update)
      delete: false,
      versions: { read: true, create: true, discard: true },
    },
  },
});
        

This drafter can:

  • Create new docs (POST /api/posts) — they land as draft (published_at = NULL).
  • Save drafts (PUT /api/posts/:id?draft=true).
  • List, view, and discard their drafts.

They cannot:

  • Publish via PUT /api/posts/:id (no update).
  • Unpublish (POST /:id/unpublish requires update).
  • Restore a version to canonical (POST /:id/versions/:vid requires update).

Pair the drafter role with an editor who has update: true and you've got a review/approval workflow with no schema additions.

Storage shape

_versions.data mirrors the canonical row's column shape: flat columns, not the nested {id, _entity} API shape. Single-target relations store the bare UUID string; polymorphic single relations store the (name, name_type) column pair. Engine-managed fields (id, timestamps, publishedAt, FK pointers, _entity, locale columns) are stripped from the snapshot so they can't override canonical on overlay reads. Multi-relations live in junction tables and aren't carried in version snapshots (a known limitation — restoring a version doesn't rewrite junctions).

The wire shape is unchanged. normalizeRelationShape runs once at the response boundary to project flat columns into nested refs.

Schema compatibility

Versions store the document's data as JSONB. Schema changes that drop fields or relax types are fine; type-incompatible changes (e.g. a text field that became a number) leave older version data unparseable against the new schema.

  • POST /versions/:vid (restore) strict-validates the stored data against the current schema and rejects with VERSION_INCOMPATIBLE if it doesn't parse. The version row itself stays in history.
  • GET /versions/:vid returns the stored JSONB as-is — useful for inspecting what the doc looked like at that schema generation.
  • The overlay read GET /:id?draft=true strips engine-managed columns from the snapshot and whitelists the rest by canonical's live column set before spreading. Fields dropped from the schema (and therefore absent from canonical) don't leak; engine state (id, timestamps, FK pointers, _entity) is never overridden even if the snapshot's JSONB is tampered. Type-incompatible changes still pass through — the consumer's Zod parse catches those.

Retention

By default the engine keeps every version row indefinitely. Cap it with limit:


          const posts = entity("posts", {
  versions: { limit: 50 },
  fields: [...],
});
        

The engine prunes oldest rows after each save. The currently-published row and the current pending draft are pinned via FK and never pruned, so the limit applies to plain history rows only.

Public API scoping

The engine does not automatically filter published_at IS NOT NULL for the public role — you set the policy. Add it to your public role's read permission:


          role("public", {
  public: true,
  permissions: {
    posts: {
      read: { filter: { publishedAt: { $exists: true } } },
    },
  },
});
        

Without this filter, the public role sees rows in any state. Best practice: every versions-enabled entity that's exposed publicly should have this filter on the public role.

What's not built in

  • Autosave. Each PUT?draft=true is one save event. Layer debouncing in the admin if you want high-frequency autosave; the engine endpoint is ready when you are.
  • Scheduled publish. Requires a background job runner. Out of scope for v1; userland can implement it with a cron + a scheduledFor field.
  • Multi-stage workflow (draft → review → approved → published). Composable from a select field + RBAC + the lifecycle hooks. The engine doesn't model state machines beyond the draft/published/modified primitive.

Previous

Hooks

Next

Assets