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

crap.collections

Collection definition and runtime CRUD operations.

crap.collections.define(slug, config)

Define a new collection. Call this in collection definition files (collections/*.lua).

crap.collections.define("posts", {
    labels = { singular = "Post", plural = "Posts" },
    fields = {
        crap.fields.text({ name = "title", required = true }),
    },
})

See Collection Definition Schema for all config options.

crap.collections.config.get(slug)

Get a collection’s current definition as a Lua table. The returned table is round-trip compatible with define() — you can modify it and pass it back.

Returns nil if the collection doesn’t exist.

local def = crap.collections.config.get("posts")
if def then
    -- Add a field
    def.fields[#def.fields + 1] = crap.fields.text({ name = "extra" })
    crap.collections.define("posts", def)
end

crap.collections.config.list()

Get all registered collections as a slug-keyed table. Iterate with pairs().

for slug, def in pairs(crap.collections.config.list()) do
    if def.upload then
        -- Add alt_text to every upload collection
        def.fields[#def.fields + 1] = crap.fields.text({ name = "alt_text" })
        crap.collections.define(slug, def)
    end
end

See Plugins for patterns using these functions.

crap.collections.find(collection, query?)

Find documents matching a query. Returns a result table with documents and pagination.

Only available inside hooks with transaction context.

local result = crap.collections.find("posts", {
    where = {
        status = "published",
        title = { contains = "hello" },
    },
    order_by = "-created_at",
    limit = 10,
    page = 1,
    depth = 1,
})

-- result.documents               = array of document tables
-- result.pagination.totalDocs    = total count (before limit/page)
-- result.pagination.limit        = applied limit
-- result.pagination.totalPages   = total pages (offset mode only)
-- result.pagination.page         = current page (offset mode only, 1-based)
-- result.pagination.pageStart    = 1-based index of first doc on this page
-- result.pagination.hasNextPage  = boolean
-- result.pagination.hasPrevPage  = boolean
-- result.pagination.prevPage     = previous page number (nil if first page)
-- result.pagination.nextPage     = next page number (nil if last page)
-- result.pagination.startCursor  = opaque cursor of first doc (cursor mode only)
-- result.pagination.endCursor    = opaque cursor of last doc (cursor mode only)

for _, doc in ipairs(result.documents) do
    print(doc.id, doc.title)
end

Query Parameters

FieldTypeDefaultDescription
wheretable{}Field filters. See Filter Operators. Supports ["or"] key for OR groups.
order_bystringnilSort field. Prefix with - for descending.
limitintegernilMax results to return.
pageinteger1Page number (1-based). Converted to offset internally.
offsetintegernilNumber of results to skip (backward compat alias for page).
after_cursorstringnilForward cursor from a previous result.pagination.endCursor. Fetches the page after the cursor position. Mutually exclusive with page/offset/before_cursor. Only effective when [pagination] mode = "cursor" in crap.toml.
before_cursorstringnilBackward cursor from a previous result.pagination.startCursor. Fetches the page before the cursor position. Mutually exclusive with page/offset/after_cursor. Only effective when [pagination] mode = "cursor" in crap.toml.
depthinteger0Population depth for relationship fields.
selectstring[]nilFields to return. nil = all fields. Always includes id. When specified, created_at and updated_at are only included if explicitly listed.
draftbooleanfalseInclude draft documents. Only affects versioned collections with drafts = true.
localestringnilLocale code for localized fields (e.g., "en", "de").
overrideAccessbooleanfalseBypass access control checks. Set to true to skip collection-level and field-level access for the current user.
searchstringnilFTS5 full-text search query. Filters results to documents matching this search term.

crap.collections.find_by_id(collection, id, opts?)

Find a single document by ID. Returns the document table or nil.

Only available inside hooks with transaction context.

local doc = crap.collections.find_by_id("posts", "abc123")
if doc then
    print(doc.title)
end

-- With population depth
local doc = crap.collections.find_by_id("posts", "abc123", { depth = 2 })

-- With field selection (only return title and status)
local doc = crap.collections.find_by_id("posts", "abc123", { select = { "title", "status" } })

Options

FieldTypeDefaultDescription
depthinteger0Population depth for relationship fields.
selectstring[]nilFields to return. nil = all fields. Always includes id.
draftbooleanfalseReturn the latest draft version snapshot instead of the published main-table data. Only affects versioned collections with drafts = true.
localestringnilLocale code for localized fields (e.g., "en", "de").
overrideAccessbooleanfalseBypass access control checks. Set to true to skip collection-level and field-level access for the current user.

crap.collections.create(collection, data, opts?)

Create a new document. Returns the created document.

Only available inside hooks with transaction context.

local doc = crap.collections.create("posts", {
    title = "New Post",
    slug = "new-post",
})
print(doc.id)  -- auto-generated nanoid

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

Options

FieldTypeDefaultDescription
localestringnilLocale code for localized fields.
draftbooleanfalseCreate as draft. Skips required field validation. Only affects versioned collections with drafts = true.
overrideAccessbooleanfalseBypass access control checks. Set to true to skip collection-level and field-level access for the current user.
hooksbooleantrueRun lifecycle hooks. Set to false to skip all hooks (before_validate, before_change, after_change) and validation. The DB operation still executes.

crap.collections.update(collection, id, data, opts?)

Update an existing document. Returns the updated document.

Only available inside hooks with transaction context.

local doc = crap.collections.update("posts", "abc123", {
    title = "Updated Title",
})

-- Draft update: saves a version snapshot only, main table unchanged
crap.collections.update("articles", "abc123", {
    title = "Still editing...",
}, { draft = true })

Options

FieldTypeDefaultDescription
localestringnilLocale code for localized fields.
draftbooleanfalseVersion-only save. Creates a draft version snapshot without modifying the main table. Only affects versioned collections with drafts = true.
unpublishbooleanfalseSet document status to draft and create a draft version snapshot. Ignores the data fields when unpublishing. Only affects versioned collections.
overrideAccessbooleanfalseBypass access control checks. Set to true to skip collection-level and field-level access for the current user.
hooksbooleantrueRun lifecycle hooks. Set to false to skip all hooks (before_validate, before_change, after_change) and validation. The DB operation still executes.

Auth Collections

For collections with auth = true, the password field is automatically handled:

  • On create, if the data contains a password key, it is extracted before hooks run, hashed with Argon2id, and stored in the hidden _password_hash column. Hooks never see the raw password.
  • On update, same pattern — if password is present and non-empty, the password is updated. Leave it out or set it to "" to keep the current password.

This matches the behavior of the gRPC API and admin UI.

crap.collections.delete(collection, id, opts?)

Delete a document. Returns true on success. For collections with soft_delete = true, moves the document to trash by default. For upload collections, associated files are cleaned up on permanent deletion (not on soft delete).

Only available inside hooks with transaction context.

-- Soft-delete (moves to trash if collection has soft_delete)
crap.collections.delete("posts", "abc123")

-- Force permanent delete even on soft-delete collections
crap.collections.delete("posts", "abc123", { forceHardDelete = true })

-- Bypass access control for internal operations
crap.collections.delete("posts", "abc123", { overrideAccess = true })

Options

FieldTypeDefaultDescription
overrideAccessbooleanfalseBypass access control checks. Set to true to skip access.trash (soft delete) or access.delete (permanent delete) checks.
hooksbooleantrueRun lifecycle hooks. Set to false to skip before_delete and after_delete hooks.
forceHardDeletebooleanfalsePermanently delete even when the collection has soft_delete = true. Requires access.delete permission when overrideAccess = false.

crap.collections.restore(collection, id)

Restore a soft-deleted document from trash. Returns true on success. Only works on collections with soft_delete = true. Re-syncs the FTS index after restore.

Only available inside hooks with transaction context.

crap.collections.restore("posts", "abc123")

Lifecycle Hooks in Lua CRUD

Lua CRUD operations run the same lifecycle hooks as the gRPC API and admin UI:

  • create: before_validate → validate → before_change → DB insert → after_change
  • update: before_validate → validate → before_change → DB update → after_change
  • update_many: per-document: before_validate → validate → before_change → DB update → after_change
  • delete: before_delete → DB delete → upload file cleanup → after_delete
  • delete_many: per-document: before_delete → DB delete → upload file cleanup → after_delete
  • find / find_by_id: before_read → DB query → after_read

All hooks have full CRUD access within the same transaction.

Hook Depth & Recursion Protection

When hooks call CRUD functions that trigger more hooks, the system tracks recursion depth via ctx.hook_depth. This prevents infinite loops:

  • Depth starts at 0 for gRPC/admin operations, 1 for Lua CRUD within hooks
  • When depth reaches hooks.max_depth (default: 3, configurable in crap.toml), hooks are automatically skipped but the DB operation still executes
  • Use ctx.hook_depth in hooks for manual recursion decisions
# crap.toml
[hooks]
max_depth = 3   # 0 = never run hooks from Lua CRUD
function M.my_hook(ctx)
    if ctx.hook_depth >= 2 then
        return ctx  -- bail early to avoid deep recursion
    end
    crap.collections.create("audit", { action = ctx.operation })
    return ctx
end

Skipping Hooks

Pass hooks = false to any write CRUD call to skip all lifecycle hooks:

-- Create without triggering any hooks
crap.collections.create("logs", { message = "raw insert" }, { hooks = false })

Access Control in Hooks

By default, all Lua CRUD functions enforce access control (overrideAccess = false). This follows the principle of least privilege — if your hook needs to bypass access checks, it must explicitly opt in with overrideAccess = true.

Breaking change (0.1.0-alpha.3): The default was changed from true to false. If you have hooks that call CRUD functions without specifying overrideAccess, they now enforce access control. Add overrideAccess = true to restore the old behavior.

When overrideAccess is false (the default), the function enforces the same access rules as the external API:

  • Collection-level access — the relevant access function (read, create, update, delete) is called with the authenticated user from the original request.
  • Field-level access — for find/find_by_id, fields the user can’t read are stripped from results. For create/update, fields the user can’t write are silently removed from the input data.
  • Constrained read access — if a read access function returns a filter table instead of true, those filters are merged into the query (same as the gRPC/admin behavior).
-- Default: access control is enforced (only shows posts the user can see)
local result = crap.collections.find("posts", {
    where = { status = "published" },
})

-- Bypass access control for internal/admin operations
local all = crap.collections.find("posts", {
    overrideAccess = true,
})

crap.collections.count(collection, query?)

Count documents matching a query. Returns an integer count.

Only available inside hooks with transaction context.

local n = crap.collections.count("posts")
local published = crap.collections.count("posts", {
    where = { status = "published" },
})

Query Parameters

FieldTypeDefaultDescription
wheretable{}Field filters. Same syntax as find.
localestringnilLocale code for localized fields.
overrideAccessbooleanfalseBypass access control checks.
draftbooleanfalseInclude draft documents.
searchstringnilFTS5 full-text search query (same as find).

crap.collections.update_many(collection, query, data, opts?)

Update multiple documents matching a query. Returns { modified = N }.

All-or-nothing semantics: finds all matching documents, checks update access for each (if overrideAccess = false), and only proceeds if all pass. If any document fails access, an error is returned and nothing is modified.

Runs the full per-document lifecycle by default: before_validate → field validation → before_change → DB update → after_change — the same pipeline as single-document update. Set hooks = false in opts to skip hooks and validation for performance on large batch operations.

Only provided fields are written (partial update). Absent fields are left unchanged — including checkbox fields, which are not reset to 0 as they would be in a full single-document update.

Only available inside hooks with transaction context.

local result = crap.collections.update_many("posts", {
    where = { status = "draft" },
}, {
    status = "published",
})
print(result.modified)  -- number of updated documents

-- Skip hooks and validation for performance
local result = crap.collections.update_many("posts", {
    where = { status = "draft" },
}, {
    status = "published",
}, { hooks = false })

Query Parameters (2nd argument)

FieldTypeDefaultDescription
wheretable{}Field filters to match documents.

Options (4th argument)

FieldTypeDefaultDescription
localestringnilLocale code for localized fields.
overrideAccessbooleanfalseBypass access control checks.
draftbooleanfalseInclude draft documents.
hooksbooleantrueRun per-document lifecycle hooks. Set to false to skip all hooks (before_validate, before_change, after_change) and field validation.

Data (3rd argument)

The data table contains fields to update on all matched documents (partial update).

crap.collections.delete_many(collection, query, opts?)

Delete multiple documents matching a query. Returns { deleted = N, skipped = N }. For upload collections, associated files are automatically cleaned up from disk for each deleted document. Documents that are still referenced by other documents are skipped (hard delete only) and reported in skipped.

All-or-nothing semantics: finds all matching documents, checks delete access for each (if overrideAccess = false), and only proceeds if all pass.

Fires per-document lifecycle hooks (before_delete, after_delete) by default. Set hooks = false in opts to skip for performance on large batch operations.

Only available inside hooks with transaction context.

local result = crap.collections.delete_many("posts", {
    where = { status = "archived" },
})
print(result.deleted)  -- number of deleted documents
print(result.skipped)  -- number skipped due to outstanding references

-- Bypass access control for internal operations
local result = crap.collections.delete_many("posts", {
    where = { status = "archived" },
}, { overrideAccess = true })

-- Skip hooks for performance
local result = crap.collections.delete_many("posts", {
    where = { status = "archived" },
}, { hooks = false })

Query Parameters (2nd argument)

FieldTypeDefaultDescription
wheretable{}Field filters to match documents.

Options (3rd argument)

FieldTypeDefaultDescription
overrideAccessbooleanfalseBypass access control checks.
hooksbooleantrueRun per-document lifecycle hooks. Set to false to skip before_delete and after_delete hooks.
localestringnilLocale code for localized fields.
draftbooleanfalseInclude draft documents.