Hooks
A `.hooks({ ... })` block attaches your code to the stages of a write, and it runs the same for the REST route, the typed client, the admin panel and your own server calls.
Where does the code that has to run on every write live? Not in a route handler, because the admin and the typed client never call yours. It lives on the collection, next to the fields it touches.
Two stages cover most collections
beforeChange shapes what gets written. afterChange reacts to what was
written.
import { collection } from "#questpie/factories";
export const posts = collection("posts")
.fields(({ f }) => ({
title: f.text(255).required(),
slug: f.text(255),
body: f.textarea(),
}))
.hooks({
// Runs after validation, before the INSERT. Mutate `data` in place.
beforeChange: ({ data, operation }) => {
if (operation === "create" && data.title && !data.slug) {
data.slug = data.title.toLowerCase().replace(/[^a-z0-9]+/g, "-");
}
},
// Runs after the write, still inside the transaction.
afterChange: async ({ data, operation, queue }) => {
if (operation !== "create") return;
await queue.notifyPost.publish(
{ postId: data.id },
{ idempotencyKey: `post-notify:${data.id}` },
);
},
});Create a post through any surface and the slug is there, though no caller sent one:
const post = await app.collections.posts.create({ title: "Hello there" });
post.slug; // "hello-there"The job is published inside the same transaction as the row, so nothing is queued for a post that failed to save.
Every stage
Each key takes one function or an array of them.
| Stage | Fires on | ctx.data is |
|---|---|---|
beforeOperation | every operation but deleteMany | the call input |
beforeValidate | create, update | the raw input, mutable |
beforeChange | create, update | the validated input |
afterChange | create, update | the saved row |
beforeRead | read | the find options |
afterRead | create, update, delete, read | the row on its way out |
beforeDelete | delete | the row about to go |
afterDelete | delete | the deleted row |
beforePurge, afterPurge | purge | a frozen copy of the row |
beforeTransition, afterTransition | a workflow stage move | the record |
Coral marks the two that run on every operation they can. beforeOperation
opens all five. afterRead closes every chain that returns a row, which is all
of them but purge, because a purged row is not there to return. The boxed step
is the database. Everything to its left can still change what lands.
Before the write
beforeValidate sees the raw input and runs before the generated schema, so it
is where you trim, lowercase and default. beforeChange sees the validated
input and is the last point where you can change what lands in the row. Mutate
ctx.data in place in both.
After the write
afterChange receives the saved row, and ctx.original carries the previous
one on an update. It runs inside the write transaction, which decides what you
may safely do in it. Side effects is that
whole story.
On the way out
afterRead runs on create, update, delete and read, after the transaction has
closed. Add a computed field, format a value, drop something. Branch on
ctx.operation to tell the four apart. beforeRead runs earlier, and its
ctx.data is the find options rather than a row.
Around a delete
beforeDelete can refuse a delete, cascade it, or take a backup. afterDelete
cleans up, and runs inside the transaction. beforePurge and afterPurge
belong to the separate irreversible purge lifecycle that
Soft delete owns. Purge returns { success: true }
and never reaches afterRead.
What a hook receives
One argument: your app context, the same flat set of services that access rules, routes and jobs get, plus the lifecycle keys.
| Key | What you get |
|---|---|
data | The payload for this stage, per the table above. |
original | The previous row on afterChange and afterRead for an update, the pre-delete row on afterDelete, the frozen copy on purge. |
operation | "create", "update", "read", "delete" or "purge", narrowed per stage. |
locale, accessMode | The active locale, and "user" or "system". |
onAfterCommit | Hold a side effect back until the write is durable. |
db, collections, globals | The database handle, and typed access to your other tables. |
queue, email, search, realtime, channels | Adapter services, alongside kv and storage. |
session, services, logger, t | The caller, your own services, the logger, translations. |
isBatch, recordIds, records, count | Batch metadata. Only in updateMany and deleteMany, see Bulk writes. |
Whatever appConfig({ context }) returns is merged in flat as well, so a tenant
id resolved once per request reaches every hook.
Written inline on .hooks({ ... }), data, original and operation are
already narrowed to this collection with no annotations. A helper in another
file needs to say what it takes, and
Typing a hook covers which type that is.
Aborting
Throw. Every stage except afterTransition propagates to the caller, and any
stage that runs before the transaction closes takes the write back with it.
import { ApiError } from "questpie/errors";
.hooks({
beforeDelete: ({ data }) => {
if (data.isProtected) {
throw ApiError.badRequest("This record cannot be deleted.");
}
},
})ApiError maps to a status the client can act on: badRequest to 400,
forbidden to 403, notFound to 404. A plain Error aborts too, but arrives
as an unexplained 500.
`afterRead` cannot undo a write
It runs once the transaction has committed. Throwing there rejects the caller's response and leaves the row in the database.
Hooks add up
Each call to .hooks() appends to the handlers already registered for a stage
rather than replacing them, and they run in registration order.
collection("posts")
.hooks({ beforeChange: normalizeTitle })
.hooks({ beforeChange: computeSlug });
// both run, in that orderThat is what lets a.merge(b) and a module contribute handlers to a collection
someone else declared. It is the opposite of .access(), which replaces its
whole object on every call. .upload() registers hooks of its own on
afterRead, afterChange, afterDelete and afterPurge, and yours run
alongside them.
Next
- Side effects, which hooks sit inside the transaction, what can join it, and what has to wait for the commit.
- Bulk writes, what
updateManyanddeleteManyfire, and which rows they fire for. - Transition hooks, the pair around a workflow stage move, which carry a different context.
- Typing a hook, sharing one handler between collections without breaking codegen.
- Access control decides who may write. Hooks decide what happens when they do.
- Validation is the schema that runs between
beforeValidateandbeforeChange. - Configuration has
appConfig({ hooks }), one handler across every collection.