Hooks API
Complete lifecycle hooks reference — all hook types, contexts, and execution order.
Overview
Hooks allow you to run custom logic at specific points in the collection/global lifecycle. Each hook can be a single function or an array of functions (executed in order).
collection("posts")
.fields(({ f }) => ({ ... }))
.hooks({
beforeValidate: ({ data, operation }) => { ... },
beforeChange: ({ data, operation }) => { ... },
afterChange: async ({ data, original, operation }) => { ... },
beforeRead: ({ data }) => { ... },
afterRead: ({ data }) => { ... },
beforeDelete: async ({ data }) => { ... },
afterDelete: async ({ data }) => { ... },
beforeOperation: ({ data, operation }) => { ... },
beforeTransition: async ({ data, fromStage, toStage }) => { ... },
afterTransition: async ({ data, fromStage, toStage }) => { ... },
})Hook Execution Order
Create
beforeOperation → beforeValidate → Schema Validation → beforeChange → DB INSERT → afterChange → afterReadUpdate
beforeOperation → beforeValidate → Schema Validation → beforeChange → DB UPDATE → afterChange → afterReadDelete
beforeOperation → beforeDelete → DB DELETE → afterDeleteRead
beforeOperation → beforeRead → DB SELECT → afterReadWorkflow Transition
beforeTransition → Version Snapshot → afterTransitionHook Types
beforeOperation
Runs before any operation. Use for logging, rate limiting, or early validation.
beforeOperation: ({ data, operation, session }) => {
console.log(`${operation} on posts by ${session?.user?.email}`);
}| Property | Type | Description |
|---|---|---|
data | TInsert | TUpdate | TSelect | Input data |
operation | "create" | "update" | "delete" | "read" | Current operation |
session | Session | null | Auth session |
db | Database | Drizzle instance |
collections | AppCollections | Collection API |
globals | AppGlobals | Globals API |
beforeValidate
Runs before schema validation on create/update. Mutate data to transform input before validation.
beforeValidate: ({ data, operation }) => {
if (operation === "create" && !data.slug) {
data.slug = slugify(data.title);
}
}| Property | Type | Description |
|---|---|---|
data | TInsert | TUpdate | Mutable input data |
operation | "create" | "update" | Current operation |
beforeChange
Runs after validation, before the database write. Mutate data to set derived fields.
beforeChange: ({ data, operation, session }) => {
if (operation === "create") {
data.createdBy = session?.user?.id;
}
}| Property | Type | Description |
|---|---|---|
data | TInsert | TUpdate | Validated input data (mutable) |
operation | "create" | "update" | Current operation |
afterChange
Runs after the database write. Use for notifications, webhooks, and side effects.
afterChange: async ({ data, original, operation, queue }) => {
if (operation === "update" && original) {
if (data.status !== original.status) {
await queue.statusChange.publish({ id: data.id, status: data.status });
}
}
if (operation === "create") {
await queue.welcome.publish({ userId: data.authorId });
}
}| Property | Type | Description |
|---|---|---|
data | TSelect | Complete record after write |
original | TSelect | undefined | Previous state (update only) |
operation | "create" | "update" | Current operation |
beforeRead
Runs before read operations. Use to modify query options or add filters.
beforeRead: ({ data }) => {
// Modify query context before execution
}afterRead
Runs after any operation that returns data. Use to transform output or add computed fields.
afterRead: ({ data }) => {
data.displayName = `${data.firstName} ${data.lastName}`;
data.isNew = Date.now() - data.createdAt.getTime() < 86400000;
}| Property | Type | Description |
|---|---|---|
data | TSelect | Complete record (mutable) |
operation | string | The operation that produced data |
beforeDelete
Runs before the database delete. Throw to prevent deletion.
beforeDelete: async ({ data, db }) => {
const hasOrders = await db.select().from(orders)
.where(eq(orders.userId, data.id));
if (hasOrders.length > 0) {
throw new Error("Cannot delete user with active orders");
}
}| Property | Type | Description |
|---|---|---|
data | TSelect | Record to be deleted |
afterDelete
Runs after the database delete. Use for cleanup and notifications.
afterDelete: async ({ data, queue }) => {
await queue.userDeleted.publish({ userId: data.id });
}| Property | Type | Description |
|---|---|---|
data | TSelect | Deleted record |
beforeTransition / afterTransition
Workflow hooks that run around stage transitions. Throw in beforeTransition to abort.
beforeTransition: async ({ data, fromStage, toStage, session }) => {
if (toStage === "published" && !data.content) {
throw new Error("Cannot publish without content");
}
},
afterTransition: async ({ data, fromStage, toStage, queue }) => {
if (toStage === "published") {
await queue.notifySubscribers.publish({ postId: data.id });
}
}| Property | Type | Description |
|---|---|---|
data | TSelect | Current record |
fromStage | string | Stage transitioning from |
toStage | string | Stage transitioning to |
locale | string | undefined | Current locale |
Common Hook Context Properties
All hooks receive these properties in addition to their type-specific ones:
| Property | Type | Description |
|---|---|---|
db | Database | Drizzle database instance |
collections | AppCollections | Typed collection API |
globals | AppGlobals | Typed globals API |
session | Session | null | Current auth session |
queue | QueueClient | Job queue client |
email | MailerService | Email service |
kv | KVStore | Key-value store |
logger | Logger | Pino logger |
app | QuestpieApp | App instance |
| Custom | any | Services from services/ |
Multiple Hook Functions
Each hook type accepts an array of functions. They execute in order:
.hooks({
afterChange: [
async ({ data }) => { /* first handler */ },
async ({ data }) => { /* second handler */ },
],
})Multiple .hooks() calls on the same builder merge their handlers:
collection("posts")
.hooks({ afterChange: handler1 })
.hooks({ afterChange: handler2 })
// Both handler1 and handler2 will run