Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Versions & Drafts

Crap CMS supports document versioning with an optional draft/publish workflow.

Enabling Versions

Add versions to your collection definition:

-- Simple: enables versions with drafts
crap.collections.define("articles", {
    versions = true,
    fields = { ... },
})

-- With options
crap.collections.define("articles", {
    versions = {
        drafts = true,
        max_versions = 20,
    },
    fields = { ... },
})

Config Properties

PropertyTypeDefaultDescription
draftsbooleantrueEnable draft/publish workflow with _status field
max_versionsinteger0Max versions per document. 0 = unlimited. Oldest versions are pruned first.

Setting versions = true is equivalent to { drafts = true, max_versions = 0 }.

Setting versions = false or omitting it disables versioning entirely.

How It Works

When versioning is enabled, every create and update operation saves a JSON snapshot of the document to a _versions_{slug} table. This provides a full audit trail with the ability to restore any previous version.

Database Changes

Versioned collections get an additional table:

_versions_articles (
    id TEXT PRIMARY KEY,
    _parent TEXT NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
    _version INTEGER NOT NULL,
    _status TEXT NOT NULL,        -- "published" or "draft"
    _latest INTEGER NOT NULL DEFAULT 0,  -- 1 for the most recent version
    snapshot TEXT NOT NULL,               -- full JSON snapshot
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now'))
)

When drafts = true, the main table also gets a _status column (TEXT NOT NULL DEFAULT 'published').

Draft/Publish Workflow

When drafts = true, documents have a _status field that is either "published" or "draft".

Creating Documents

ActionResult
Create (publish)Document inserted with _status = 'published' + version snapshot
Create (draft)Document inserted with _status = 'draft' + version snapshot

Updating Documents

ActionResult
Update (publish)Main table updated, _status = 'published' + new version snapshot
Update (draft)Version-only save — main table is NOT modified, only a new draft version snapshot is created
Unpublish_status set to 'draft' + new version snapshot

The version-only draft save is key: it lets authors iterate on changes without affecting the published version. The main table always reflects the last published state.

Reading Documents

API CallDefault Behavior
FindReturns only _status = 'published' documents
Find with draft = trueReturns all documents (published + draft)
FindByIDReturns the main table document (published version)
FindByID with draft = trueReturns the latest version snapshot (may be a newer draft)

Validation

Required field validation is skipped for draft saves. This lets authors save incomplete work. Validation is enforced when publishing (draft = false).

gRPC API

Draft Parameter

The draft parameter is available on these RPCs:

// Create a draft
CreateRequest { collection, data, draft: true }

// Draft update (version-only, main table unchanged)
UpdateRequest { collection, id, data, draft: true }

// Find all documents including drafts
FindRequest { collection, draft: true }

// Get the latest version (may be a newer draft)
FindByIDRequest { collection, id, draft: true }

ListVersions

List version history for a document:

grpcurl -plaintext -d '{
    "collection": "articles",
    "id": "abc123",
    "limit": "10"
}' localhost:50051 crap.ContentAPI/ListVersions

Response:

{
    "versions": [
        { "id": "v1", "version": 3, "status": "draft", "latest": true, "created_at": "..." },
        { "id": "v2", "version": 2, "status": "published", "latest": false, "created_at": "..." },
        { "id": "v3", "version": 1, "status": "published", "latest": false, "created_at": "..." }
    ]
}

RestoreVersion

Restore a previous version, writing its snapshot data back to the main table:

grpcurl -plaintext -d '{
    "collection": "articles",
    "document_id": "abc123",
    "version_id": "v3"
}' localhost:50051 crap.ContentAPI/RestoreVersion

This overwrites the main table with the snapshot data, sets _status to "published", and creates a new version entry for the restore.

Lua API

The draft option is available on create and update:

-- Create as draft
local doc = crap.collections.create("articles", {
    title = "Work in progress",
}, { draft = true })

-- Draft update (version-only save)
crap.collections.update("articles", doc.id, {
    title = "Still editing...",
}, { draft = true })

-- Publish
crap.collections.update("articles", doc.id, {
    title = "Final Title",
})  -- draft defaults to false

Admin UI

Buttons

When drafts are enabled, the edit form shows context-aware buttons:

Document StatePrimary ButtonSecondary ButtonExtra
Create (new)PublishSave as Draft
Editing (draft)PublishSave Draft
Editing (published)UpdateSave DraftUnpublish

Status Badge

A status badge (published or draft) appears in the document meta panel and in the collection list view.

Version History

The edit sidebar shows a “Version History” panel listing recent versions with:

  • Version number
  • Status badge (published/draft)
  • Timestamp
  • Restore button (for non-latest versions)

Clicking Restore writes the snapshot data back to the main table and redirects to the edit form.

Access Control

Draft operations use the existing update access rule. There is no separate access rule for drafts. If you need finer-grained control (e.g., only admins can publish, but editors can save drafts), inspect the incoming data._status field in your access hooks:

function hooks.access.publish_control(ctx)
    if ctx.data and ctx.data._status == "draft" then
        -- Any authenticated user can save drafts
        return ctx.user ~= nil
    end
    -- Only admins can publish
    return ctx.user and ctx.user.role == "admin"
end

Versions Without Drafts

You can enable version history without the draft/publish workflow:

versions = {
    drafts = false,
    max_versions = 50,
}

This creates version snapshots on every save but does not add a _status column, does not filter by publish state, and does not show draft/publish buttons in the admin UI. Useful for pure audit trails.

Example

crap.collections.define("articles", {
    labels = { singular = "Article", plural = "Articles" },
    timestamps = true,
    versions = {
        drafts = true,
        max_versions = 20,
    },
    admin = {
        use_as_title = "title",
        default_sort = "-created_at",
    },
    fields = {
        crap.fields.text({ name = "title", required = true }),
        crap.fields.text({ name = "slug", required = true, unique = true }),
        crap.fields.textarea({ name = "summary" }),
        crap.fields.richtext({ name = "body" }),
    },
    access = {
        read   = "hooks.access.public_read",
        create = "hooks.access.authenticated",
        update = "hooks.access.authenticated",
        delete = "hooks.access.admin_only",
    },
})