QUESTPIE
Schema

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.

View markdown

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.

src/questpie/server/collections/posts.ts
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.

StageFires onctx.data is
beforeOperationevery operation but deleteManythe call input
beforeValidatecreate, updatethe raw input, mutable
beforeChangecreate, updatethe validated input
afterChangecreate, updatethe saved row
beforeReadreadthe find options
afterReadcreate, update, delete, readthe row on its way out
beforeDeletedeletethe row about to go
afterDeletedeletethe deleted row
beforePurge, afterPurgepurgea frozen copy of the row
beforeTransition, afterTransitiona workflow stage movethe record
Mermaid

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.

KeyWhat you get
dataThe payload for this stage, per the table above.
originalThe 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, accessModeThe active locale, and "user" or "system".
onAfterCommitHold a side effect back until the write is durable.
db, collections, globalsThe database handle, and typed access to your other tables.
queue, email, search, realtime, channelsAdapter services, alongside kv and storage.
session, services, logger, tThe caller, your own services, the logger, translations.
isBatch, recordIds, records, countBatch 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.

src/questpie/server/collections/posts.ts
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 order

That 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 updateMany and deleteMany fire, 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 beforeValidate and beforeChange.
  • Configuration has appConfig({ hooks }), one handler across every collection.

On this page