# Hooks (/docs/schema/hooks)

---
title: Hooks
description: 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.
kind: guide
package: questpie
---

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.

```ts title="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:

```ts
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               |

```mermaid
flowchart LR
  subgraph create
    direction LR
    c1(beforeOperation) --> c2(beforeValidate) --> c3(beforeChange) --> cDB[[INSERT]] --> c4(afterChange) --> c5(afterRead)
  end
  subgraph update
    direction LR
    u1(beforeOperation) --> u2(beforeValidate) --> u3(beforeChange) --> uDB[[UPDATE]] --> u4(afterChange) --> u5(afterRead)
  end
  subgraph delete
    direction LR
    d1(beforeOperation) --> d2(beforeDelete) --> dDB[[DELETE]] --> d3(afterDelete) --> d4(afterRead)
  end
  subgraph purge
    direction LR
    p1(beforeOperation) --> p2(beforePurge) --> pDB[[PURGE]] --> p3(afterPurge)
  end
  subgraph read
    direction LR
    r1(beforeOperation) --> r2(beforeRead) --> rDB[[SELECT]] --> r3(afterRead)
  end

  classDef always fill:var(--primary),stroke:var(--primary),color:var(--primary-foreground)
  classDef db fill:var(--surface-high),stroke:var(--border-strong),color:var(--foreground)
  class c1,u1,d1,p1,r1,c5,u5,d4,r3 always
  class cDB,uDB,dDB,pDB,rDB db
```

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](/docs/schema/hooks/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](/docs/schema/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](/docs/schema/hooks/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](/docs/schema/hooks/typing)** 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.

```ts title="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.

<Callout type="warn" title="`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.
</Callout>

## 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.

```ts
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](/docs/schema/hooks/side-effects)**, which hooks sit inside
  the transaction, what can join it, and what has to wait for the commit.
- **[Bulk writes](/docs/schema/hooks/bulk-writes)**, what `updateMany` and
  `deleteMany` fire, and which rows they fire for.
- **[Transition hooks](/docs/schema/hooks/transitions)**, the pair around a
  workflow stage move, which carry a different context.
- **[Typing a hook](/docs/schema/hooks/typing)**, sharing one handler between
  collections without breaking codegen.
- [Access control](/docs/schema/access-control) decides who may write. Hooks
  decide what happens when they do.
- [Validation](/docs/schema/validation) is the schema that runs between
  `beforeValidate` and `beforeChange`.
- [Configuration](/docs/ship/configuration) has `appConfig({ hooks })`, one
  handler across every collection.
