QUESTPIE
Reference

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 → afterRead

Update

beforeOperation → beforeValidate → Schema Validation → beforeChange → DB UPDATE → afterChange → afterRead

Delete

beforeOperation → beforeDelete → DB DELETE → afterDelete

Read

beforeOperation → beforeRead → DB SELECT → afterRead

Workflow Transition

beforeTransition → Version Snapshot → afterTransition

Hook 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}`);
}
PropertyTypeDescription
dataTInsert | TUpdate | TSelectInput data
operation"create" | "update" | "delete" | "read"Current operation
sessionSession | nullAuth session
dbDatabaseDrizzle instance
collectionsAppCollectionsCollection API
globalsAppGlobalsGlobals 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);
  }
}
PropertyTypeDescription
dataTInsert | TUpdateMutable 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;
  }
}
PropertyTypeDescription
dataTInsert | TUpdateValidated 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 });
  }
}
PropertyTypeDescription
dataTSelectComplete record after write
originalTSelect | undefinedPrevious 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;
}
PropertyTypeDescription
dataTSelectComplete record (mutable)
operationstringThe 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");
  }
}
PropertyTypeDescription
dataTSelectRecord 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 });
}
PropertyTypeDescription
dataTSelectDeleted 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 });
  }
}
PropertyTypeDescription
dataTSelectCurrent record
fromStagestringStage transitioning from
toStagestringStage transitioning to
localestring | undefinedCurrent locale

Common Hook Context Properties

All hooks receive these properties in addition to their type-specific ones:

PropertyTypeDescription
dbDatabaseDrizzle database instance
collectionsAppCollectionsTyped collection API
globalsAppGlobalsTyped globals API
sessionSession | nullCurrent auth session
queueQueueClientJob queue client
emailMailerServiceEmail service
kvKVStoreKey-value store
loggerLoggerPino logger
appQuestpieAppApp instance
CustomanyServices 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

On this page