13. Hooks and lifecycle
Hooks extend particular lifecycle stages. Their names, ordering and context shape depend on the operation. A write can invoke read hooks while preparing its full response, and relationship writes can invoke nested resource writes.
An operation passes the caller’s mutable context through its resource hooks. Hooks can cache information, add properties for later hooks, and change the documented writable fields at the appropriate stage. Those changes remain visible to the caller after success or failure. There is no immutable context wrapper or separate hook cache API.
Use Writing Plugins for reusable extensions, field transformations for setters/getters and computed output, and row policies for mandatory visibility constraints.
Register and observe hooks
The following blocks form one runnable example. Insert them in order into the
starting script, before starting its server,
on a fresh database. Register named handlers through api.customize.
await api.addResource('notes', {
schema: {
title: { type: 'string', required: true },
canEdit: { type: 'boolean', virtual: true }
}
})
await api.resources.notes.createKnexTable()
const events = []
api.customize({
hooks: {
beforeProcessingPost: {
functionName: 'prepare-note-title',
handler: async ({ context }) => {
if (context.scopeName !== 'notes') return
const title = context.inputRecord?.data?.attributes?.title
context.noteTitleCache = typeof title === 'string' ? title.trim() : undefined
}
},
beforeSchemaValidatePost: {
functionName: 'normalize-note-title',
handler: async ({ context }) => {
if (context.scopeName !== 'notes') return
const attributes = context.inputRecord.data.attributes
if (context.noteTitleCache !== undefined) attributes.title = context.noteTitleCache
events.push(`validate:${context.method}`)
}
},
beforeDataCallPost: {
functionName: 'observe-note-write',
handler: async ({ context }) => {
if (context.scopeName === 'notes') events.push(`write:${context.method}`)
}
},
beforeDataGet: {
functionName: 'observe-note-read',
handler: async ({ context }) => {
if (context.scopeName === 'notes') events.push(`read:${context.method}`)
}
},
finishPost: {
functionName: 'observe-note-finish',
handler: async ({ context }) => {
if (context.scopeName !== 'notes') return
context.savedNoteId = String(context.id)
events.push(`finish:${context.method}`)
}
},
afterCommit: {
functionName: 'observe-note-commit',
handler: async ({ context }) => {
if (context.scopeName === 'notes') events.push(`commit:${context.method}`)
}
}
}
})
The first hook caches information; a later hook uses it to change the pending title before validation; the finish hook adds information for the caller. The input type is checked before using a string method because processing hooks run before validation. For an ordinary field transformation, prefer a schema setter; this example demonstrates communication between hook stages.
const noteContext = {}
const fullNote = await api.resources.notes.post({
data: { title: ' First note ' }, format: 'plain', returning: 'full'
}, noteContext)
const fullEvents = events.splice(0)
console.log(fullNote.title, noteContext.savedNoteId, fullEvents)
The title is First note. The event sequence is validate:post, write:post,
read:get, finish:post, commit:post. The full response invokes a nested GET
before the write finishes; its context has method get.
const minimalNote = await api.resources.notes.post({
data: { title: ' Second note ' }, format: 'plain', returning: 'minimal'
})
const minimalEvents = events.splice(0)
console.log(minimalNote, minimalEvents)
The minimal result contains id and type: 'notes'. The events omit read:get.
returning: 'none' also avoids that response GET. Do not put mandatory write
validation in read hooks: it would depend on the requested return mode.
Cache permission information across hooks
The input-preparation recipe above already shows two hooks sharing a cache.
Permissions can use the same approach. These next recipes extend that runnable
example. The lookup below stands in for an application authorization service;
auth.userId must come from trusted application code.
import { RestApiResourceError } from 'json-rest-api'
let permissionLookups = 0
async function loadNotePermissions (userId) {
permissionLookups++
return { canEdit: userId === 'editor' }
}
await api.customize({ hooks: {
checkPermissions: {
functionName: 'authorize-note-edit',
handler: async ({ scopeName, context }) => {
if (scopeName !== 'notes') return
const operation = context.originalContext ?? context
operation.notePermissions ??= await loadNotePermissions(operation.auth?.userId)
if (context.method === 'patch' && !operation.notePermissions.canEdit) {
throw new RestApiResourceError('Editing notes is forbidden', { subtype: 'forbidden' })
}
}
}
} })
const permissionContext = { auth: { userId: 'editor' } }
const editedNote = await api.resources.notes.patch({
id: fullNote.id, data: { title: 'Edited note' }
}, permissionContext)
const lookupsAfterEdit = permissionLookups
The write and its full-response GET perform one lookup in total. The permission
hook writes to the originating operation context through originalContext for
ordinary CRUD. Relationship permission hooks receive the operation directly,
so the fallback handles that boundary too. A nested response GET inherits the
cached object by reference. Later hooks read notePermissions from the relevant
operation; they do not need another lookup or a global cache.
This recipe permits reads and gates PATCH only. Define the allowed methods for your application explicitly; use row policies when authorization must filter collections or relationship targets. A cached decision is valid only for the identity and conditions it was calculated for. Use a fresh context for a new call.
Enrich a declared output field
canEdit was declared as a virtual boolean above, so it can appear in output
without a stored column. This hook obtains the cached permission through the
enrichment wrapper’s parentContext:
await api.customize({ hooks: {
enrichAttributes: {
functionName: 'show-note-edit-permission',
handler: ({ scopeName, context }) => {
if (scopeName !== 'notes') return
context.attributes.canEdit = context.parentContext.notePermissions?.canEdit ?? false
}
}
} })
const editableNote = await api.resources.notes.get({ id: fullNote.id }, {
auth: { userId: 'editor' }
})
const readOnlyNote = await api.resources.notes.get({ id: fullNote.id }, {
auth: { userId: 'reader' }
})
The returned canEdit values are true and false. This output is information for
the caller; the write permission hook enforces access. Final field selection
still applies, so a sparse request for title alone omits canEdit. Prefer a
declared computed field for a value derived from resource attributes; use
enrichment when it depends on request-specific information such as this cache.
Perform an effect after commit
The original example observes commit order. An application effect belongs at
that same boundary, rather than in a finish hook. This runnable stand-in records
notifications in an array; replace publishNoteChanged with your application
integration when using the recipe.
const notifications = []
async function publishNoteChanged (change) {
notifications.push(change)
}
await api.customize({ hooks: {
afterCommit: {
functionName: 'publish-note-change',
handler: async ({ scopeName, context }) => {
if (scopeName !== 'notes' || context.method !== 'patch') return
await publishNoteChanged({
id: String(context.id), title: context.minimalRecord.attributes.title
})
}
}
} })
let notificationsBeforeCommit
await api.transaction(async transaction => {
await api.resources.notes.patch({
id: fullNote.id, data: { title: 'Ready to share' }, transaction
}, { auth: { userId: 'editor' } })
notificationsBeforeCommit = notifications.length
})
const notificationsAfterCommit = notifications.slice()
There are no notifications inside the transaction callback, and one after
api.transaction resolves. A rollback does not run this hook. An effect failure
after commit cannot roll back the saved note; it is reported with a committed
outcome. This is not durable delivery across process failure. Use a
transactional outbox when reliable eventual delivery is a requirement.
Context depends on the boundary
data selects plain application input and document selects a JSON:API input
document. format: 'plain' and format: 'jsonapi' select output independently.
Resource POST/PUT/PATCH normalize either input into context.inputRecord, an
internal JSON:API document, before their processing hooks. The public
inputRecord argument has been removed; the hook field retains its name and
shape. It is not yet validated at the early processing boundary. Reads and
DELETE do not have their own write document; relationship writes accept
relationshipData instead.
| Boundary | Where to find operation data |
|---|---|
| Resource processing, validation, data and finish hooks | context is the same object supplied as the method’s second argument, or a fresh object when omitted. Inspect method and scopeName before using method-specific properties. |
Resource authorization through checkPermissions |
Ordinary CRUD uses a permission wrapper whose originalContext points to the operation. Relationship methods invoke the hook with their operation context directly. Use context.originalContext ?? context for information shared with later operation hooks. |
| Attribute enrichment | Hooks share a per-resource enrichment wrapper. context.attributes is its output attribute object; context.parentContext is the read operation context. Included resources receive their own enrichment calls. |
| Full write response | A nested GET has a separate, shallow-copied context. It shares the write’s transaction and referenced application objects. |
inputRecord.data.attributes contains write attributes. After setters run,
after-data hooks see transformed values. minimalRecord is internal stored
resource data; responseRecord holds the selected write output once prepared.
POST assigns its generated ID before after-data hooks. PATCH processing hooks
can run before context.id is populated; use later validated stages when an ID
is required. PUT selects its create/update branch before schema validation.
Do not use internal representation flags as application options. The public
options are format and returning; see API migration.
Getters and computed callbacks have their own documented
contexts and dependency rules.
Field ownership and mutation
The context is mutable, but not every property is an application input. Use application-specific names for cached values and additional information so they do not collide with the library fields below. The library does not freeze the context or enforce ownership through proxies.
| Field | Owner and availability | Supported mutation and effect |
|---|---|---|
Application properties, such as auth, noteTitleCache or savedNoteId |
Caller and application hooks; available once assigned. | Add or update them to communicate between hooks. Trust/authentication comes from application code, never automatically from write data. |
inputRecord.data.attributes |
Library-normalized write input, available at processing hooks. | Prepare attributes in beforeProcessing* or beforeSchemaValidate*; schema validation follows. After-validation changes are not schema-validated again. Setters run after beforeDataCall*. Changes after the storage call do not update the database. |
inputRecord.data.relationships |
Library-normalized write input. | Prepare linkage in beforeProcessing*, before document validation, relationship authorization and relationship planning. Later changes do not rebuild the already prepared relationship operations. |
record |
Library-created JSON:API read result, available after fetching. | Read enrichment and finish hooks can adjust output. Final normalization, visibility checks and field projection still apply. This does not change stored values. |
attributes on an enrichment wrapper |
Library-created output attributes for one resource. | enrichAttributes hooks can update declared output fields; the returned attributes remain subject to final projection. Use parentContext for operation-wide cached information. |
responseRecord |
Selected write output, available at write finish; absent for returning: 'none'. |
Finish hooks can adjust the response before final normalization and commit. A returned write result is copied before completion hooks; mutating this field in afterCommit does not change that result. |
method, scopeName, id, relationshipName, isUpdate |
Library-owned operation identity and branch selection. Availability varies by method and stage. | Inspect rather than overwrite. Changing them does not select a different operation or safely retarget a write. POST’s ID is available after storage; normalize it with String(context.id) when comparing it to public IDs. |
schemaInfo, params, queryParams, format, returning, simplified |
Library-owned compiled schema, request parameters and selected response behavior. | Set public options in method arguments. Do not overwrite these fields to redirect processing. Use schema declarations, row policies and documented query hooks for customization. |
minimalRecord, originalMinimalRecord, originalInputAttributes, originalRecord |
Library-owned stored data and snapshots; availability depends on the stage. | Inspect them when available. Do not change snapshots to influence validation, permissions or persistence. |
transaction, db, shouldCommit, transactionOutcome, transactionCommitted, error, cleanupErrors |
Library-owned transaction and failure state. | Inspect through the documented transaction/error contract. Pass transaction handles through method arguments; do not replace these fields, forge outcomes or erase diagnostics. |
knexQuery, storageAdapter |
Temporary state at native query hook boundaries, also visible to nested query permission checks. | knexQuery is a filtering envelope; its query property holds the native builder. Use the documented filtering contract, and do not retain a builder for a later operation. |
Fields belonging to optional plugins follow those plugins’ documented hooks. Other implementation fields are not extension points simply because they are visible on the context. A hook can throw to reject an operation; a late change to validated attributes must not be used to bypass authorization or validation.
Use a fresh context for every independent operation, especially concurrent calls. The same object is not an isolated request template: it contains loaded records and transaction state after a call. To share authentication or a cache, pass those values deliberately in new context objects. Nested objects are not deep-cloned, so concurrent application hooks must coordinate shared mutations.
Editor help for known hooks
Known hook names provide stage-specific context types when registered through
api.customize or resource hooks. For a separately declared handler, select its
hook name explicitly:
import type { HookHandler } from 'json-rest-api'
type NoteContext = {
auth?: { userId: string }
notePermissions?: { canEdit: boolean }
}
const showEditPermission: HookHandler<'enrichAttributes', NoteContext> = ({ context }) => {
context.attributes.canEdit = context.parentContext.notePermissions?.canEdit ?? false
}
Register showEditPermission as the handler of the named enrichment hook in the
recipe above. Use RuntimeHooks<NoteContext> to annotate a separate hook map.
JavaScript projects can use the same types through JSDoc and editor type checking.
The editor distinguishes an enrichment wrapper’s parentContext from a
permission wrapper’s originalContext, and marks library-owned fields such as
method and transaction readonly. Application cache properties and documented
write/output values remain mutable. Readonly annotations do not freeze runtime
objects or add another hook mechanism.
These types describe known boundaries, not a proof of the whole lifecycle. Early input and arbitrary attribute values still need narrowing; a custom plugin owns its custom hook context. Types cannot prove authorization, mutation order, transaction completion or the safety of shared nested objects. Keep the runtime contracts and focused hook tests alongside the editor guidance.
Nested context inheritance
Built-in nested operations use separate context objects with shallow inheritance:
| Boundary | Inherited information | Separate working state |
|---|---|---|
| Full write-response GET | Application properties and referenced cache/auth objects; the active transaction is passed explicitly. Write input is available for virtual-field response enrichment. | GET sets its own method, ID, schema, query selection and read result. Replacing a child top-level property does not replace the parent’s property. |
| Relationship PATCH and reverse relationship child writes | Application properties and referenced objects; the active transaction is passed explicitly. | Child writes set their own method, input, schema and stored records. The parent retains its own operation identity and finish context. |
| Bulk children | Application properties and referenced objects; atomic children share the batch transaction. | Each child has its own context with bulkOperation and bulkIndex. Child cleanup diagnostics are collected by the batch. |
| Ordinary CRUD permission checks and attribute enrichment | A reference to the relevant operation through originalContext or parentContext. |
A fresh wrapper for that permission/enrichment call. Adding a wrapper property does not add it to the originating context. Relationship permission hooks instead receive the relationship operation context directly. |
| Included-resource and relationship visibility queries | Application context and the active connection/transaction. | The library sets the target resource/schema and query selection; parent filters are not blindly applied as target filters. |
Shallow inheritance means a child sees an existing context.cache object by
reference: updating that object’s contents is visible to the parent, whereas
assigning a different context.cache affects only the child. Library snapshots,
input and transaction bookkeeping remain subject to the ownership rules above.
Do not treat a child’s copied write fields as that child’s input: a nested GET
can retain information about the enclosing write for response enrichment.
For an application-initiated nested call, construct a new context containing the
application properties the child actually needs, and pass transaction in the
method arguments when it must participate in the parent’s transaction. Passing
the parent’s context object directly would let the child overwrite its method,
ID and other working fields. A blanket spread also inherits existing internal
fields; it is not a substitute for choosing the information to share.
Resource write order
POST, PUT-create, PUT-update and PATCH follow this sequence. The method suffix
is Post, Put or Patch, for example beforeDataCallPatch.
- Create or borrow a transaction and select response behavior.
beforeProcessing, then the method-specific processing hook.- Validate the request contract and relationships, and perform the
method-specific existence/replacement checks. Run
beforeSchemaValidate, then its method-specific hook; validate attributes; run the method-specificafterSchemaValidate, thenafterSchemaValidate. - Authorize through
checkPermissions; runbeforeDataCall, then its method-specific hook. - Acquire required relationship locks, apply setters and write to storage.
- Run the method-specific
afterDataCall, thenafterDataCall. - Apply relationship changes and refresh the minimal stored record.
- Prepare the response; run
finish, then its method-specific hook. - Commit an owned transaction and await its
afterCommitchain.
DELETE has no processing, schema-validation or setter stage. Lookup and
permission checks precede beforeDataCall and beforeDataCallDelete.
Deletion precedes afterDataCallDelete, afterDataCall, finish and
finishDelete. DELETE returns no record.
The write hooks are named beforeDataCall and afterDataCall. beforeData
is a read hook. Registering beforeDataPost or afterDataPost does not attach
a handler to these resource write stages.
Read order and output enrichment
GET authorizes through checkPermissions, then runs beforeData and
beforeDataGet. After fetching, it runs checkDataPermissions,
checkDataPermissionsGet and enrichRecord. Field getters and computed output
run during attribute enrichment, followed by enrichAttributes.
enrichRecordWithRelationships, finish and finishGet follow. Final
normalization, field projection and representation conversion happen afterward.
QUERY authorizes through checkPermissions, runs beforeData and
beforeDataQuery, fetches the collection, then runs enrichRecord.
Attribute enrichment covers primary and included records. finish and
finishQuery precede final projection and conversion. Do not infer QUERY
hook names or stages from the GET sequence.
Use declared computed fields for derived attributes so dependencies and sparse
fieldsets work together. An enrichAttributes hook operates on one attribute
object, whereas enrichRecord operates on the response document. Adding an
undeclared output key does not establish a supported field or bypass final
field filtering. Finish hooks are late observers; they are not a substitute
for schema validation or query policy.
Relationship and bulk composition
Relationship POST and DELETE run beforeDataCall, followed by
beforeDataCallPostRelationship or beforeDataCallDeleteRelationship, after
resolving the visible parent and before mutation. Their context exposes
scopeName, id, transaction, and minimalRecord. A shared beforeDataCall
hook must account for postRelationship and deleteRelationship methods too.
Socket.IO uses this boundary to query subscription membership before a change;
its eligibility queries also run the ordinary query lifecycle in the write’s
transaction with the subscriber’s trusted context.
Relationship writes acquire database locks after the beforeDataCall hooks.
Belongs-to and polymorphic targets are rechecked while locking them. Collection
mutations also preserve a parent row version so concurrent replacements cannot
use stale membership; a database UPDATE trigger may run even when the parent’s
attribute values did not change. These internal operations do not invoke another
resource hook. Keep borrowed transactions short, and leave rollback/retry of
deadlock or serialization failures to the outer owner. See
relationship migration notes
for the driver behavior and retry boundaries.
PATCH relationship authorizes through checkPermissions and
checkPermissionsPatchRelationship, then invokes a source-resource PATCH with
returning: 'none', followed by finish and finishPatchRelationship.
POST/DELETE relationship invoke their own permission, before-data and finish
hooks. Reverse relationship mutations can run child PATCH lifecycles inside
the parent’s operation. Direct pivot/link changes do not synthesize
pivot-resource hook calls.
Bulk operations invoke child resource lifecycles. Atomic children share a transaction; non-atomic children complete separate transactions. Do not count hook invocations as independent user requests or assume one context per batch.
Completion and failure
Each hook is awaited. A failing ordinary hook stops later stages of its chain.
For an owned pre-commit failure, the operation attempts rollback;
afterRollback runs only after acknowledged rollback. After acknowledged
commit, an afterCommit failure reports a committed outcome and cannot undo
the mutation. Uncertain driver completion runs neither outcome hook.
With an explicit api.transaction handle, participating calls finish their
write stages while completion hooks wait for the owner. A rejected participant
poisons the managed unit even if its caller catches the error. Atomic children
complete in enlistment order after commit and reverse order after rollback.
Keep external side effects consistent with those boundaries; a finish hook
runs before commit.
See managed transactions and
transaction outcomes for ownership, typed failures
and retry guidance. RestApiWriteError.cause retains the error received at the
write boundary; it cannot recover an error replaced earlier by an extension
or the hook dispatcher.
Registration-time extensions
Resource compilation exposes schema:enrich for stored attribute definitions,
searchSchema:enrich for filters, and then computedSchema:enrich for computed
output definitions. These run at registration and during canonical field
addition. Derived fields should use the computed hook so supported recompilation
rebuilds them. See the computed enrichment contract and migration example.
For plugin installation, scope registration, custom methods and helpers, use Writing Plugins. Register resource-specific behavior explicitly and guard global hooks by scope and method. Keep request state on its operation context rather than in shared plugin variables.