Hook Context
Collection-level hooks receive a context table and must return a (potentially modified) context table.
Context Shape
{
collection = "posts", -- Collection slug
operation = "create", -- "create", "update", "delete", "find", "find_by_id", or "init"
data = { -- Document data (mutable in before-write hooks)
title = "Hello World",
slug = "hello-world",
status = "draft",
id = "abc123", -- Present on update/delete, absent on create
created_at = "...", -- Present on read/update
updated_at = "...",
},
locale = "en", -- Locale for this operation (nil if not locale-specific)
draft = true, -- Whether this is a draft save (versioned collections only)
hook_depth = 0, -- Current recursion depth (0 = top-level, 1+ = from Lua CRUD in hooks)
context = { -- Request-scoped shared table (persists across all hooks in the same request)
-- Hooks can read and write arbitrary keys here to share data
},
user = { -- Authenticated user document (nil if unauthenticated)
id = "user_123",
email = "admin@example.com",
role = "admin",
-- ... all fields from the auth collection
},
ui_locale = "en", -- Admin UI locale (nil if not set)
}
Typed Contexts
The type generator (crap-cms typegen) emits per-collection context types with typed
data fields for IDE autocomplete:
- Collections:
crap.hook.{PascalCase}— e.g.,crap.hook.Postshasdata: crap.data.Postsandcollection: "posts"(literal) - Globals:
crap.hook.global_{slug}— e.g.,crap.hook.global_site_settingshasdata: crap.global_data.SiteSettings
Use the typed context for hooks that target a specific collection:
---@param context crap.hook.Posts
---@return crap.hook.Posts
return function(context)
-- context.data.title, context.data.slug, etc. autocomplete
return context
end
Delete hooks receive only { id = "..." } in data (not full document fields),
so they use the generic crap.HookContext:
---@param context crap.HookContext
---@return crap.HookContext
return function(context)
local id = context.data.id
return context
end
For shared hooks that fire across multiple collections (e.g., via
crap.hooks.register()), use the generic crap.HookContext.
Data Mutation
In before-write hooks (before_validate, before_change), you can modify ctx.data and return the modified context. The changes flow through to the database write.
function M.auto_slug(ctx)
if not ctx.data.slug or ctx.data.slug == "" then
ctx.data.slug = crap.util.slugify(ctx.data.title or "")
end
return ctx
end
In after-read hooks, you can also modify ctx.data to transform the response before it reaches the client.
Return Value
Hooks must return the context table (or a new table with data). If a hook returns:
- A table with a
datakey — the data is replaced - A table without a
datakey — the original data is kept - A non-table value — the original context is kept
System Fields in Data
| Field | Present When | Description |
|---|---|---|
id | update, delete, read | Document ID |
created_at | read, update | ISO 8601 timestamp |
updated_at | read, update | ISO 8601 timestamp |
user | write hooks, after_read (nil if unauthenticated) | Authenticated user document |
ui_locale | write hooks, after_read (nil if not set) | Admin UI locale code |
On create, id is not yet assigned (it’s generated by the database write).
Draft Field
For versioned collections with drafts = true, the context includes a draft field:
| Value | Meaning |
|---|---|
true | This is a draft save (required field validation is skipped) |
false | This is a publish save (full validation applied) |
nil | Collection does not have versioning enabled |
You can use this in hooks to customize behavior based on publish state:
function M.before_change(ctx)
if ctx.draft then
-- Draft save: skip expensive operations
return ctx
end
-- Publishing: run full processing
ctx.data.published_at = os.date("!%Y-%m-%d %H:%M:%S")
return ctx
end
User
The user field contains the full authenticated user document from the auth collection, or nil if the request is unauthenticated (or no auth collection exists). This is the same user document used by access control functions.
function M.before_change(ctx)
if ctx.user then
ctx.data.last_edited_by = ctx.user.email
end
return ctx
end
UI Locale
The ui_locale field contains the admin UI locale code (e.g., "en", "de"), or nil if not set. This is useful for returning user-facing messages (e.g., validation errors) in the correct language.
function M.validate_title(value, ctx)
if not value or value == "" then
if ctx.ui_locale == "de" then
return "Titel ist erforderlich"
end
return "Title is required"
end
return true
end
Hook Depth
The hook_depth field tracks how deep in the hook→CRUD→hook chain the current execution is:
| Value | Meaning |
|---|---|
0 | Top-level call from gRPC API or admin UI |
1 | Called from Lua CRUD inside a hook |
2+ | Deeper recursion (hook called CRUD which triggered another hook) |
When hook_depth reaches hooks.max_depth (default: 3, configurable in crap.toml),
hooks are automatically skipped but the DB operation still executes. This prevents infinite
recursion when hooks create/update documents in the same collection.
function M.audit_hook(ctx)
-- Only audit at the top level, not from recursive hook calls
if ctx.hook_depth >= 1 then
return ctx
end
crap.collections.create("audit_log", {
action = ctx.operation,
collection = ctx.collection,
})
return ctx
end
Context (Request-Scoped Shared Table)
The context field is a request-scoped table that persists across all hooks in the same request. It allows hooks to share data with each other without relying on module-level state.
Each hook in the chain receives the same context table, and any modifications made by one hook are visible to all subsequent hooks in the same request. The table starts empty at the beginning of each request.
This is useful for:
- Passing computed values from
before_validatetobefore_changewithout recomputing - Sharing request metadata between hooks
- Accumulating data across multiple hooks for use in
after_change
-- In before_validate hook: compute and share a value
function M.before_validate(ctx)
ctx.context.original_title = ctx.data.title
return ctx
end
-- In after_change hook: use the shared value
function M.after_change(ctx)
if ctx.context.original_title ~= ctx.data.title then
crap.log.info("Title changed from: " .. (ctx.context.original_title or "nil"))
end
return ctx
end
Note: The
contexttable is not the same as module-level variables. Module-level variables persist across requests on the same VM (see Hooks Overview), whilecontextis scoped to a single request and automatically cleaned up.