Hooks System
The hooks system provides data transformation and side effects during database operations.
Overview
Hooks allow you to:
- Transform data before it's saved to the database
- Transform data before it's returned to the user
- Perform validation beyond basic field rules
- Trigger side effects (logging, notifications, etc.)
Hook Types
List-Level Hooks
Defined at the list level, these hooks run for all operations on the list:
Post: list({
fields: { title: text(), status: text(), publishedAt: timestamp() },
hooks: {
resolveInput: async ({ resolvedData, operation }) => {
if (operation === 'create' && resolvedData.status === 'published') {
resolvedData.publishedAt = new Date()
}
return resolvedData
},
validate: async (args) => {
if (args.operation === 'delete') return
const title = args.resolvedData.title
if (typeof title === 'string' && title.includes('spam')) {
args.addValidationError('Title cannot contain spam')
}
},
beforeOperation: async ({ operation }) => {
console.log(`About to ${operation} a post`)
},
afterOperation: async (args) => {
if (args.operation === 'update') {
console.log('Changed from:', args.originalItem, 'to:', args.item)
}
},
beforeTransaction: async ({ listKey, operation }) => {
await audit.recordIntent(listKey, operation)
},
afterTransaction: async ({ listKey, operation, status }) => {
if (status === 'rolled-back') await audit.withdrawIntent(listKey, operation)
},
},
})
validate is the current name; validateInput is kept as a deprecated alias for Keystone compatibility and behaves identically. beforeTransaction and afterTransaction run outside the write's transaction and are for non-transactional side effects only — the section below explains the split.
Field-Level Hooks
Defined on individual fields. A field hook is handed fieldKey, and reads its own value out of resolvedData[fieldKey]. resolveOutput is handed the value directly, as value:
fields: {
password: password({
hooks: {
resolveInput: async ({ resolvedData, fieldKey }) => {
const plaintext = resolvedData[fieldKey]
if (typeof plaintext !== 'string') return undefined
return await bcrypt.hash(plaintext, 10)
},
resolveOutput: ({ value }) => (typeof value === 'string' ? mask(value) : value),
},
}),
}
Hook Execution Order
Write Operations (create/update)
Everything below sits inside the write's transaction except step 0 and the beforeTransaction/afterTransaction bracket around the whole thing.
- Operation-level access check — a denial short-circuits to
nullhere, before any hook runs. A denied write fires no hooks at all, boundary hooks included. - List-level
resolveInput— transform input data at list level - Field-level
resolveInput— transform individual field values - List-level
validate— custom validation logic - Field-level
validate— custom validation for individual fields - Field validation — built-in rules (
isRequired, length, min/max) - Field-level access control — filter writable fields
- Relation resolution — a
connectbecomes a foreign key, ornullclears one. An unreachable target ends the write asnull, before step 8. - Field-level
beforeOperation— side effects for individual fields - List-level
beforeOperation— side effects at list level - Database operation
- List-level
afterOperation— side effects at list level - Field-level
afterOperation— side effects for individual fields
A delete skips steps 1–2 and 5–7: there is no input to shape, so it runs validate (list, then field), then the beforeOperation/afterOperation brackets around the database call.
Validation is the one failure that is not silent: steps 3–5 throw a ValidationError. Every other refusal on this list answers null.
Read Operations (query)
- Database operation
- Field-level access control — filter readable fields
- Field-level
resolveOutput— transform individual field values, and compute virtual fields
There is no beforeOperation/afterOperation on a read. resolveOutput is the only hook a query runs.
In-transaction vs transaction-boundary hooks
Every write runs inside one database transaction (see ADR-0010). The side-effect hooks split into two families by where they run relative to that transaction:
In-transaction hooks —
beforeOperation/afterOperation. They run inside the transaction and roll back with it. Use them for work that must be atomic with the write — typically further database work throughcontext.db(which the pipeline binds to the transaction for the duration of the write; so iscontext.ormHandle, the engine's own ORM handle, if you have a reason to skip the secured surface). A throwingafterOperationrolls the write back. Do not make non-transactional external calls (HTTP, email, billing) here: holding a transaction open across a network call is bad, and such calls can't be rolled back.Transaction-boundary hooks —
beforeTransaction/afterTransaction. They run outside the transaction and form a compensation bracket around it:beforeTransactionruns before the transaction opens.afterTransactionruns after it settles and always runs (when its pairedbeforeTransactionran), receiving the outcome:status: 'committed' | 'rolled-back'. Oncommittedit gets the persisteditem; onrolled-backit gets theerrorthat caused the rollback and noitem— so it can undo whateverbeforeTransactiondid externally.
Both fire per list involved in the write (the top-level list plus each nested create/update/delete list). The bracket is symmetric: a list's
afterTransactionruns if and only if itsbeforeTransactionran, so every external action taken has its paired compensator. A throwingbeforeTransactionaborts the write (the transaction never opens) and triggersafterTransaction(rolled-back) only for the lists whosebeforeTransactionalready ran. If anafterTransactionitself throws, the remaining compensators still run and the error is reported toonAfterTransactionError(defaultconsole.error) — it never changes the write's result. Sudo does not affect these hooks; they always run.Important caveats for these hooks:
item/originalItemare populated only for the TOP-LEVEL record. Oncommitted, the persisteditem(andoriginalItemfor update/delete) is surfaced only for the top-level list; for nested lists they areundefined. The per-record persisted row is not reliably recoverable outside the transaction, and these hooks fire at list (not record) granularity. For per-record nested compensation use the in-transactionafterOperation, which receives the correct nesteditem. Transaction-boundary hooks on nested lists are for external-call compensation keyed offstatus/inputData.- Granularity is per-
(list, operation), not strictly per-list. A list reached under two operations (e.g. a nestedcreateand a nestedupdateof the same list) fires its boundary hooks twice — once per operation — and only the first nested record'sinputDatafor that operation is surfaced. Many nested records of the same(list, operation)fire the bracket once. - Write your compensators to be idempotent. A bracket can fire for an involvement that turns out to write no row, so a no-op compensation must be safe to run.
afterTransactionreports the OUTERMOST transaction, not the write's own return (ADR-0028). A write that joins a transaction it did not open — one made insidecontext.transaction(), or a hook's owncontext.dbwrite — cannot itself observe when that enclosing transaction settles. ItsafterTransactionis deferred until the transaction owner (context.transaction(), or the Write Pipeline when it opened the transaction) observes the real settle, then fires with that outcome.beforeTransactionstays eager in every case — only the pairedafterTransactionis deferred.- Status is a conjunction:
committedif and only if the write itself succeeded and the enclosing transaction committed; otherwiserolled-back. A write's own error always wins over the transaction's outcome — a write that failed on its own reportsrolled-backeven if everything else in the transaction went on to commit. - The deferred
itemis stale-safe, not stale-free. It is the row as that write persisted it, captured at write time — not re-read at flush — so a later write to the same record in the same transaction leaves it stale in what the compensator sees. - A rejection always means the write did not persist. An
afterTransactionthat throws — list or field level, on a commit or a rollback — never rejects the write orcontext.transaction(). Its error is handed to the top-levelonAfterTransactionErrorcallback (witherror,status,listKeyandoperation), or toconsole.errorwhen none is configured or the callback throws. A transaction error still reaches the caller as before. beforeTransactioncan now run with the transaction already open. Undercontext.transaction()it runs on the write's way in, so it holds that transaction open for its duration. Keep it fast, or hoist slow external work abovecontext.transaction()— acontext.dbwrite from inside it can otherwise block on rows the transaction itself is writing.- A write with no transaction owner at all — a client that cannot open one, such as a bare test double, with no
context.transaction()wrapping it — still firesafterTransactionoptimistically at write time: there is no owner to defer to and no settle to wait for.
- Status is a conjunction:
Compensation pattern
Pair an external action in beforeTransaction with its undo in afterTransaction's rolled-back branch. Both hooks are discriminated unions over operation and (for afterTransaction) status, so narrow before reaching for a field — a delete rollback carries no inputData at all:
hooks: {
beforeTransaction: async (args) => {
if (args.operation !== 'create') return
await billing.reserveSeat(args.inputData.seatId)
},
afterTransaction: async (args) => {
if (args.operation !== 'create') return
if (args.status === 'rolled-back') {
await billing.releaseSeat(args.inputData.seatId)
return
}
if (args.item !== undefined) await billing.confirmSeat(args.item.seatId)
},
}
Hook Arguments
Every hook receives an object, and every one of those objects carries listKey, operation and context — the AccessContext, whose members are session, db, storage, plugins and ormHandle. The rest differs per hook and per operation, because the shapes are discriminated unions on operation rather than one wide type with everything optional: a create's resolveInput has no item, and TypeScript will not let you read one.
The names that recur:
inputData— the data as the caller passed it, before any transformation.resolvedData— the data as theresolveInputchain has left it so far.item— the existing row. Absent oncreate; present onupdate/delete.originalItem— the pre-write row, onafterOperationfor update and delete.fieldKey— on field hooks only, the field this hook belongs to. The one exception is fieldresolveOutput, which names itfieldName.addValidationError(msg)— onvalidatehooks only.
The Config API reference gives each hook's exact argument shape per operation.
Common Use Cases
Timestamps
createdAt and updatedAt are not something to write a hook for. Set db: { timestamps: true } — on one list, or on db for every list at once — and the generator adds both. createdAt takes a database default; updatedAt is maintained by the write pipeline.
Reach for resolveInput for a domain timestamp instead — one whose value is a decision rather than a clock reading:
resolveInput: async ({ resolvedData, operation }) => {
if (operation === 'update' && resolvedData.status === 'published') {
resolvedData.publishedAt = new Date()
}
return resolvedData
}
Slug Generation
A field-level resolveInput returns the field's own new value, not the whole payload. Two rules decide whether it can do this job at all, and both point the same way:
A field resolveInput only runs when its own key is already in the payload — if (!(fieldKey in result)) continue (packages/core/src/hooks/index.ts:468). On the create this recipe is written for, where the caller supplies title and omits slug, a field hook on slug never fires. And its return value is assigned unconditionally (hooks/index.ts:484), so returning undefined does not leave the existing value alone — it writes undefined.
Deriving a field the caller did not send is therefore list-level work. A list-level resolveInput always runs and returns the whole payload, so it can add a key that was not there:
Post: list({
fields: {
title: text({ validation: { isRequired: true } }),
slug: text({ isIndexed: 'unique' }),
},
hooks: {
resolveInput: async ({ resolvedData }) => {
const { slug, title } = resolvedData
if (typeof slug === 'string' && slug.length > 0) return resolvedData
if (typeof title !== 'string') return resolvedData
return {
...resolvedData,
slug: title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, ''),
}
},
},
})
Use a field resolveInput for the job it is shaped for: transforming a value the caller did send — hashing a password, normalising a phone number, trimming whitespace.
Cache Invalidation
afterOperation narrows on operation: only update carries both item and originalItem, and delete carries originalItem alone.
afterOperation: async (args) => {
if (args.operation === 'delete') {
await redis.del(`post:${args.originalItem.id}`)
return
}
await redis.del(`post:${args.item.id}`)
if (args.operation === 'update' && args.originalItem.status !== args.item.status) {
console.log(`Status changed from ${args.originalItem.status} to ${args.item.status}`)
}
}
Audit Logging
A hook's context.db is bound to the write's own transaction, so the audit row rolls back with the write it describes. It is also still access controlled — a create the session may not perform answers null rather than throwing, so check it if the audit row is load-bearing:
beforeOperation: async ({ listKey, operation, context }) => {
const entry = await context.db.AuditLog.create({
data: {
listName: listKey,
operation,
userId: context.session?.userId ?? 'anonymous',
},
})
if (entry === null) throw new Error(`Could not record an audit entry for ${listKey}.${operation}`)
}
What a resolveOutput hook may read
A resolveOutput hook is handed exactly its field's declared needs plus the list's system fields — never what the caller selected. That is what keeps a computed field's value the same from every call site, and Queries & projections explains the mechanism.
The cost: undeclared reads, dead branches, and the second hop
Three prices come with that guarantee, and they are worth naming outright.
An undeclared read breaks silently — unless you are typed. A hook that reads a column its field did not declare in needs finds undefined there at runtime, and nothing tells you: the field just computes the wrong value. Typed through the generated Lists.<List>.TypeInfo — the documented pattern, list<Lists.Post.TypeInfo>({ … }) — it is a compile error instead, because item is narrowed to precisely the declared set. Annotate your lists; the declaration is only enforced where you asked for it to be.
A declared-only branch computes nothing. When needs names a relation, the engine fetches that relation's stored columns for your hook and stops. It does not run the related list's own computed fields over it, because those fields' dependency sets were never fetched — a hook reading one there would dereference undefined and fail the whole read. So a declared relation gives you data, not a fully resolved row.
Two hops cost a privileged read. needs is one hop and non-transitive: it names stored columns and immediate relations on the same list, nothing further. A value two relations away is not something you can declare — the hook has to go and read it, through a context that can see it, and pay for that read on every row it runs on.
Enforcing a state machine
transitionGuard() returns a list-level validate hook that enforces allowed transitions on a select field, optionally keyed by a stored sibling field. It runs for every caller, sudo() included, and checks a value set by resolveInput the same as one the caller supplied.
import { list, transitionGuard } from '@opensaas/stack-core'
Operation: list({
fields: {/* action, state */},
hooks: {
validate: transitionGuard({
field: 'state',
discriminator: 'action',
initial: { EDIT: ['PREPARED'] },
allowed: { EDIT: { PREPARED: ['DISPATCHING'] } },
}),
},
})
- Create: the state must be in
initial[key]. - Update that changes the field: the new state must be in
allowed[key][previous]. - Update that changes only the discriminator: the current state must be legal under the new key.
- Update that changes neither, and delete: not checked.
Omit discriminator for a plain guard: initial is a flat array and allowed a flat from → to[] map. The guard reads the row loaded for the write and takes no lock, so two concurrent updates from the same state can both pass. Where that matters, read the row under .forUpdate() inside context.transaction. A create that omits the field is checked as undefined, because database defaults are not visible to the hook.
To combine the guard with your own validate, call both:
const guard = transitionGuard({ field: 'state', initial: ['DRAFT'], allowed: { DRAFT: ['SENT'] } })
hooks: {
validate: async (args) => {
await guard(args)
if (args.operation === 'delete') return
// ...your own checks
},
}
Best Practices
1. Keep Hooks Pure
Avoid side effects in resolveInput and resolveOutput. Use beforeOperation and afterOperation for side effects:
// ✅ Good: Pure transformation
resolveInput: ({ resolvedData }) => {
resolvedData.title = resolvedData.title.trim()
return resolvedData
}
// ❌ Bad: Side effects in resolveInput
resolveInput: async ({ resolvedData, context }) => {
await sendEmail() // Don't do this here!
return resolvedData
}
2. Use Async When Needed
All hooks can be async:
resolveInput: async ({ resolvedData }) => {
const result = await someAsyncOperation()
resolvedData.field = result
return resolvedData
}
3. Return Modified Data
Always return the modified data from resolveInput:
// ✅ Good: Returns modified data
resolveInput: ({ resolvedData }) => {
resolvedData.slug = generateSlug(resolvedData.title)
return resolvedData
}
// ❌ Bad: Doesn't return
resolveInput: ({ resolvedData }) => {
resolvedData.slug = generateSlug(resolvedData.title)
// Missing return!
}
Next Steps
- Access Control - Secure your data
- Queries & projections - What a read returns, and what a hook is handed
- Context API -
context.transaction(), row locks and the unsafe surface - Field Types - Available field types
- Custom Fields - Create custom field types