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: {/* ... */},
hooks: {
resolveInput: async ({ resolvedData, operation, context }) => {
// Transform input data before database operation
if (operation === 'create' && resolvedData.status === 'published') {
resolvedData.publishedAt = new Date()
}
return resolvedData
},
validateInput: async ({ operation, resolvedData, addValidationError }) => {
if (operation === 'delete') return
// Custom validation logic
if (resolvedData.title?.includes('spam')) {
addValidationError('Title cannot contain spam')
}
},
beforeOperation: async ({ operation, resolvedData, context }) => {
// Side effects before database operation
console.log(`About to ${operation} a post`)
},
afterOperation: async ({ operation, item, originalItem, context }) => {
// Side effects after database operation
if (operation === 'create') {
// Send notification, invalidate cache, etc.
}
if (operation === 'update' && originalItem) {
// Compare previous and new values
console.log('Changed from:', originalItem, 'to:', item)
}
},
beforeTransaction: async ({ operation, inputData }) => {
// OUTSIDE the transaction — non-transactional side effects only.
},
afterTransaction: async (args) => {
// OUTSIDE the transaction — always runs; compensate on rollback.
if (args.status === 'rolled-back') {
// undo whatever beforeTransaction did externally (args.error explains why)
}
},
},
})
Field-Level Hooks
Defined on individual fields:
fields: {
password: password({
hooks: {
resolveInput: async ({ resolvedData, fieldKey }) => {
// Hash password before saving
const plaintext = resolvedData[fieldKey]
if (plaintext) {
return await bcrypt.hash(plaintext, 10)
}
},
resolveOutput: async ({ item, fieldKey }) => {
// Wrap with HashedPassword class
return new HashedPassword(item[fieldKey])
},
},
}),
}
Hook Execution Order
Write Operations (create/update)
- List-level
resolveInput- Transform input data at list level - Field-level
resolveInput- Transform individual field values - List-level
validateInput- Custom validation logic - Field validation - Built-in rules (isRequired, length, min/max)
- Field-level access control - Filter writable fields
- 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
Read Operations (query)
- Database operation
- Field-level access control - Filter readable fields
- Field-level
resolveOutput- Transform individual field values - Field-level
afterOperation- Side effects for individual fields
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/context.prisma(which the pipeline binds to the transaction for the duration of the write). 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(s) are surfaced afterward — the database state is already final. 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. connectOrCreateis enumerated as create-involvement best-effort. AconnectOrCreatethat resolves to connect (the row already exists) still fires the bracket as acreateinvolvement even though no row is written. Write your compensators to be idempotent so a no-op write is safe to compensate.
Compensation pattern
Pair an external action in beforeTransaction with its undo in afterTransaction's rolled-back branch:
hooks: {
beforeTransaction: async ({ operation, inputData }) => {
// Non-transactional side effect — reserve an external resource.
await billing.reserveSeat(inputData.seatId)
},
afterTransaction: async (args) => {
if (args.status === 'rolled-back') {
// The DB write did not persist — release what beforeTransaction reserved.
await billing.releaseSeat(args.inputData.seatId)
} else {
// Committed — finalize the external action.
await billing.confirmSeat(args.item.seatId)
}
},
}
Hook Context
All hooks receive a context object with relevant information:
interface HookContext {
operation: 'create' | 'update' | 'delete' | 'query'
session: Session | null
context: Context
listKey: string
resolvedData?: any // For input hooks
item?: any // Current item (after operation)
originalItem?: any // Original item before operation (for update/delete)
originalInput?: any // Original input before transformations
}
Common Use Cases
Auto-Set Timestamps
resolveInput: async ({ resolvedData, operation }) => {
if (operation === 'create') {
resolvedData.createdAt = new Date()
}
if (operation === 'update') {
resolvedData.updatedAt = new Date()
}
return resolvedData
}
Slug Generation
fields: {
slug: text({
hooks: {
resolveInput: async ({ resolvedData, item, operation }) => {
// Generate slug from title if not provided
if (!resolvedData.slug && resolvedData.title) {
return resolvedData.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
},
},
}),
}
Password Hashing
password: password({
hooks: {
resolveInput: async ({ resolvedData, fieldKey }) => {
const plaintext = resolvedData[fieldKey]
if (plaintext) {
return await bcrypt.hash(plaintext, 10)
}
},
},
})
Cache Invalidation
afterOperation: async ({ operation, item, originalItem, context }) => {
if (['create', 'update', 'delete'].includes(operation)) {
// Invalidate cache
await redis.del(`post:${item.id}`)
// For updates, you can compare previous and new values
if (operation === 'update' && originalItem) {
if (originalItem.status !== item.status) {
console.log(`Status changed from ${originalItem.status} to ${item.status}`)
}
}
}
}
Audit Logging
beforeOperation: async ({ operation, resolvedData, context }) => {
await context.db.auditLog.create({
data: {
operation,
userId: context.session?.userId,
timestamp: new Date(),
data: resolvedData,
},
})
}
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
- Field Types - Available field types
- Custom Fields - Create custom field types