Hooks
Run code at fixed points in the framework, or change what it produces.
A hook is a named event the framework fires at a fixed point in its lifecycle: the server starting, a record about to be written, a response about to be sent to the socket. You register a callback for the name, and the framework calls it when the moment comes. Middleware runs inside each request's pipeline and can answer the request. A hook, by contrast, is process-wide: you register it once, and it fires every time its event happens, in a request or not.
import { hook } from 'ohnejs';
hook('server:ready', async ({ host, port }) => {
await warmCache();
console.log(`accepting connections at http://${host}:${port}`);
});Registering
hook(name, fn) appends the callback to the hook's chain, and checks its signature against the hook's declared type, so an unknown name or a wrong parameter is a compile error. Register from a boot file: boot runs before the port opens, so the callback is ready the first time the hook fires.
- When a hook fires, its callbacks run in the order they were registered. Each one is awaited before the next, so a mix of sync and async callbacks always runs in the same order.
- Across layers, the furthest layer boots first, so a base layer's callbacks run before yours.
Actions and filters
Every hook is one of two kinds, and the kind tells you what a callback may do:
- An action returns nothing and runs for its side effect:
server:readywarms a cache,record:committedfires a webhook. Its return value is ignored, and if a callback throws, it aborts whatever fired the hook. - A filter threads a value through its callbacks. Its first argument is that value, and each callback's return value replaces it for the next callback. The framework uses the last return value.
Two rules apply to every filter, so the hooks below do not repeat them:
- Returning
undefinedmeans "no change", so a callback can handle the cases it cares about and return nothing for the rest. - When the threaded value is a mutable object, you may change it in place and return nothing instead of returning a replacement.
import { hook } from 'ohnejs';
hook('response:headers', (headers) => {
headers.set('X-Frame-Options', 'DENY');
});Since undefined means "no change", a filter can never thread undefined as a value.
The hooks at a glance
Every built-in hook, by the lifecycle it belongs to. The table holds all but auth:account-layout, which the account page covers.
| Hook | Kind | Fires |
|---|---|---|
server:ready | action | the API server is listening |
request:complete | action | a dispatched request finished, background work done |
middleware:resolve | filter | a request's middleware list resolved, before it runs |
handler:result | filter | a handler returned, before its value serializes |
error:response | filter | a request threw, on the error response |
response:send | filter | a dispatched response is about to return to the transport |
response:headers | filter | any response's headers, the last step before the socket |
record:before-change | filter | a create or update input, before coercion |
record:validate | filter | a create or update, after coercion, before the write |
record:after-create | filter | a created record, re-read, before create returns |
record:before-update | action | an update, before it writes its rows |
record:after-update | action | each record an update touched, re-read |
record:condition | filter | an update or delete WHERE, before it matches rows |
record:before-delete | action | a delete, before it removes its rows |
record:committed | action | a write committed, outside the transaction |
query:filter | filter | a read's QueryIR, before it compiles to SQL |
query:records | filter | a row read's assembled records, before it returns |
query:complete | action | a row read finished, with its timing |
populate:targets | filter | a populate node's target records, before they are attached |
schema:synced | action | the schema reconcile committed |
dashboard:menu | filter | the dashboard sidebar resolved, before discovery answers |
dashboard:meta | action | the discovery payload assembled, before GET /dashboard answers |
auth:account-layout | filter | the account page layout, and what PATCH /auth/me accepts |
Server lifecycle
server:ready
Runs once the API server is listening, after the socket accepts and before readiness is announced. Warm a cache, open a pool, or announce the address to discovery, as the snippet at the top of this page does.
- The callback receives the bound
hostandport. Readportto learn the real port whenapi.portis0. - To clean up, register
onShutdowninstead. It runs when the process shuts down, and afterohne syncand every command, whereserver:readynever fires.
request:complete
Fires once a dispatched request finishes: the response is written and every waitUntil promise has settled. Use it when a trace must also cover background work, instead of ending when the response is sent.
import { hook } from 'ohnejs';
hook('request:complete', (event) => {
log.info('request', {
method: event.request.method,
path: event.url.pathname,
ip: event.ip,
});
});- It runs outside the request context, so read the passed
event, not the composables. - A router miss, a refused host, and a path outside the base path are never dispatched, so none of them fire it.
The request pipeline
These hooks run in two places:
- Inside the request context, where the composables work:
middleware:resolve,handler:result, anderror:response. - At the transport edge, after the request context:
response:sendandresponse:headers. Read the arguments the callback receives, not the composables.
middleware:resolve
Filters or reorders the middleware for a request, after the globals and the route's selection resolve. The list arrives with the globals first, then the route's selection, and you return the names to run.
It exists for the cases that need dynamic, per-request control, shown in middleware. Routine selection belongs on the route.
handler:result
Filters a handler's raw return value before it serializes into a Response. Use it to reshape every result, like wrapping it in an envelope:
import { hook } from 'ohnejs';
import { isArray, isPlainObject } from 'ohnejs/utils';
hook('handler:result', (result) =>
isPlainObject(result) || isArray(result) ? { data: result } : undefined,
);- It fires for a handler result and for a middleware short-circuit, each before serialization runs.
- The replacement serializes by the same rules: a
Response, anHTTPError, a string, or JSON. - A returned
HTTPErroralso goes through this hook. A thrown error goes toerror:responseinstead.
The guard above wraps only a plain object or array. A Response, a returned HTTPError, a string, an empty body, or a stream passes through.
error:response
Filters the response built when a request throws. Return a replacement Response, such as a branded error page or a body with the detail removed. Or report the error and return nothing:
import { hook } from 'ohnejs';
hook('error:response', (response, error) => {
reportToSentry(error);
if (response.status === 500) {
return new Response('Something went wrong.', { status: 500 });
}
});- It fires for a mapped
HTTPErrorand for an unhandled error's generic500. - A timeout
503fromhandlerTimeoutdoes not throw, so it does not count as an error and does not fire this hook. - The callback receives the error response, the thrown
error, and theevent. - It runs before
response:send, which then sees whatever this returns.
response:send
Filters the finished response of a dispatched request, just before it returns to the transport:
import { hook } from 'ohnejs';
hook('response:send', (response, event) => {
if (event.url.pathname.startsWith('/api/')) {
response.headers.set('Cache-Control', 'no-store');
}
});- It fires for every dispatched outcome: a handler result, a middleware short-circuit, a mapped
HTTPError, the generic500, the timed-out503. - A router miss (
404/405) never enters dispatch and skips it. Useresponse:headersto cover those too.
response:headers
Filters a response's outgoing headers, the last step before they are written to the socket. Set or remove a header in place, or return a replacement Headers:
import { hook } from 'ohnejs';
hook('response:headers', (headers) => {
headers.set('X-Frame-Options', 'DENY');
headers.set('X-Content-Type-Options', 'nosniff');
});- Unlike
response:send, it covers every response, including those that never enter dispatch: a router404/405, a rejected-host400, a base-path miss. - The read-only
responseis passed so you can read the status.
Writing records
These hooks fire around create, update, and delete:
- Most run inside the write transaction, so a callback that writes on the passed
txcommits atomically with the change, or not at all. - Each carries a
ctxthat names thecollection, so check it to target one collection.
record:before-change
Filters the raw input of a create or update before the pipeline coerces it. One hook covers every caller, so a timestamps, tenant, or audit layer can set its values on every input:
import { hook } from 'ohnejs';
hook('record:before-change', (input, ctx) => {
input.updatedAt = new Date().toISOString();
if (ctx.operation === 'create') input.createdAt = input.updatedAt;
});- It fires once per create or update call, however many records the update matches, inside the transaction, before the input is frozen.
- A create with
localesfires it once more per locale, as anupdatecarrying that locale's values. ctx.localeis the locale the write stores.
record:validate
Filters the field errors of a create or update. Use it to add the cross-field or cross-collection failures that the per-field validator cannot express:
import { hook } from 'ohnejs';
hook('record:validate', (errors, scope, ctx) => {
if (ctx.collection !== 'Events') return;
const values = scope.values as { startsAt?: string; endsAt?: string };
if (values.startsAt && values.endsAt && values.endsAt < values.startsAt) {
return { ...errors, endsAt: 'End must come after start' };
}
});- It fires after coercion, before any precheck or write, inside the transaction.
- The threaded value is the errors so far, keyed by field path. Spread it to add keys, or return your own to replace it.
- A non-empty result aborts the write as
{ ok: false, errors }, and nothing is written. Returningundefinedor an empty map lets the write continue. - Read the coerced values from
scope.values. - A create with
localesfires it once more per locale, as anupdateat that locale. ctx.localeis the locale the write stores.
record:after-create
Filters a freshly created record, re-read in its final state, just before the create returns. A search-index, revision, or audit write here commits atomically with the record:
import { hook, query } from 'ohnejs';
hook('record:after-create', async (record, ctx) => {
if (ctx.collection !== 'Posts') return;
await query('SearchIndex').use(ctx.tx).create({
ref: record.UUID as string,
text: `${record.title} ${record.body}`,
});
});- It fires inside the transaction.
- A create with
localesfires it once, after every locale is written. - Return a replacement record to reshape what the caller receives.
- The
ctxcarries thecollection, the opentx, and the effectivelocale.
record:before-update
Runs once per update just before it writes, carrying the UUIDs of the records it will change. Use it to read what a record held before the write, then act on the change in record:after-update:
import { hook, query, type Transaction } from 'ohnejs';
const before = new WeakMap<Transaction, Map<string, string>>();
hook('record:before-update', async (ctx) => {
if (ctx.collection !== 'Posts') return;
const rows = await query('Posts')
.use(ctx.tx)
.where('UUID', (w) => w.in([...ctx.matched]))
.select('UUID', 'title')
.findMany();
before.set(ctx.tx, new Map(rows.map((row) => [row.UUID, row.title])));
});
hook('record:after-update', async (record, ctx) => {
const post = record.UUID as string;
const old = before.get(ctx.tx)?.get(post);
if (ctx.collection !== 'Posts' || old === undefined || old === record.title) return;
await query('TitleHistory').use(ctx.tx).create({ post, title: old });
});- It fires inside the transaction, after validation, and only when the update matched a record.
- The
ctxcarries thecollection, the scopedcondition, thematchedUUIDs, the opentx, and the effectivelocale. - Keep it read-only. A uniqueness or reference check can still fail after it, and that failure does not always undo what a callback wrote.
- A
deleteTranslationnever fires it.
record:after-update
Runs for each record an update touched, re-read in its final state. Use it to write something atomically for each record, like a search-index row or a revision, on the same tx:
import { hook, query } from 'ohnejs';
hook('record:after-update', async (record, ctx) => {
if (ctx.collection !== 'Posts') return;
await query('SearchIndex')
.use(ctx.tx)
.where('ref', record.UUID as string)
.update({ text: `${record.title} ${record.body}` });
});- It fires once per matched record, inside the transaction, so it is genuinely per-row.
- It is an action: the record passes through unchanged.
record:condition
Filters the WHERE condition of an update or delete before it finds the rows it touches. Return a narrowed condition to force a scope on the write, such as a tenant filter or a soft-delete guard:
import { hook } from 'ohnejs';
import { currentTenant } from '../lib/tenant.ts';
hook('record:condition', (condition, ctx) => {
if (ctx.collection !== 'Posts') return;
const scope = {
kind: 'compare' as const,
path: ['tenant'],
op: 'equalsTo' as const,
value: currentTenant(),
negated: false,
};
return { kind: 'and', nodes: [condition, scope] };
});- It fires once per update or delete call, not per row, outside the transaction.
- The condition is a
ConditionNodefromohnejs/utils. Combine your clause with it using AND and return the new node, or return nothing to leave the caller's condition as is.
record:before-delete
Runs just before a delete removes its rows, carrying the UUIDs of the rows it will remove. Use it to clean up rows outside the cascade, such as an external mirror or a derived table, on the same tx:
import { hook, query } from 'ohnejs';
hook('record:before-delete', async (ctx) => {
if (ctx.collection !== 'Posts') return;
await query('Attachments')
.use(ctx.tx)
.where('post', (w) => w.in([...ctx.matched]))
.delete();
});- It fires inside the transaction.
- The
ctxcarries thecollection, the scopedcondition, thematchedUUIDs, and the opentx. - It fires only when it or
record:committedhas a callback registered. Without one, the fast delete never lists the rows it removes. - A
deleteTranslationnever fires it, since every record it matches survives.
record:committed
Runs after a write commits. Use it for external effects that must never run on a rollback. Clearing a cache, calling a webhook, or updating an external index is safe here:
import { hook } from 'ohnejs';
hook('record:committed', async ({ collection, operation, uuids }) => {
if (collection !== 'Posts') return;
await fetch('https://hooks.example.com/posts', {
method: 'POST',
body: JSON.stringify({ operation, uuids }),
});
});- It fires after the commit, outside the transaction.
- A
.use(tx)write fires it once the transaction it joined commits, whether you opened it withuseDatabase().transactionor it belongs to another write, like a revision row written inrecord:after-update. A write that rolled back never fires it. - The payload carries the
collection, theoperation, and the affecteduuids. - A
deleteTranslationreports anupdateof the records that lost the locale. - A create with
localesreports onecreate, every locale included.
Reading records
These hooks fire around reads. They scope the query before it runs and shape the rows after. Their payloads are typed QueryIR and QueryRecord: import those from ohnejs to type a callback you declare separately from its hook call.
query:recordsandquery:completefire only for reads that return records.count,exists, andpluck's column path return a scalar and fire neither.- Junction
UUIDlists, child composites, and blocks load outside bothquery:filterandpopulate:targets, so neither hook sees them.
query:filter
Filters the frozen QueryIR before a read compiles it to SQL, so one scope reaches every read. Add a scoping condition with AND here: a tenant key, a soft-delete deletedAt IS NULL, an ACL clause.
import { hook } from 'ohnejs';
hook('query:filter', (ir) => {
if (ir.collection !== 'Posts') return;
const live = { kind: 'compare' as const, path: ['deletedAt'], op: 'isNull' as const, negated: false };
return {
...ir,
condition: ir.condition ? { kind: 'and', nodes: [ir.condition, live] } : live,
};
});- It fires once per SQL statement:
findMany,findFirst,count,exists, andpluck's column path. Apaginatefires it twice, once for its count and once for its rows. - A
queryUntypedchain with.unscoped()skips it, and skipsrecord:conditionon a write. Use that for bookkeeping an app scope must never reach, like checking that no user exists yet.query:recordsstill runs on the rows it returns. - The
QueryIRis frozen, so mutating it throws. Spread it, add your clause tocondition, wrapped in anandwhen a condition already exists, and return the rebuilt IR. - It does not reach populated targets. Use
populate:targetsto scope populated relations.
query:records
Filters the whole assembled rowset once, after hydrate and populate, just before a row read returns. Add computed fields, remove sensitive output, or decrypt values that are encrypted at rest:
import { hook } from 'ohnejs';
hook('query:records', (records, { collection }) => {
if (collection !== 'Users') return;
for (const record of records) delete record.passwordHash;
});- It fires for
findMany,findFirst, andpluck's record fallback. - It runs once over the array, never per row.
- The
contextcarries thecollectionand the resolvedir.
query:complete
Runs once when a row read finishes, carrying its timing and shape. It is an action for logging or metrics:
import { hook } from 'ohnejs';
hook('query:complete', ({ collection, rowCount, durationMs }) => {
if (durationMs > 100) {
console.warn(`slow read on ${collection}: ${rowCount} rows in ${durationMs}ms`);
}
});- It fires for every read that returns records, including
pluck's record fallback. To timecount,exists, andpluck's column path, measure at the database adapter. durationMsspans compile, query, hydrate, populate, andquery:records, on the monotonic clock.rowCountis the returned count after every transform.
populate:targets
Filters the target records a populate node has just read in a batch, before they are attached to their parents. Drop a soft-deleted or unauthorized target here:
import { hook } from 'ohnejs';
hook('populate:targets', (targets, { collection }) => {
if (collection !== 'Posts') return;
return targets.filter((target) => target.deletedAt === null);
});- A dropped target leaves a
recordlinknulland removes arecordselement, and its whole subtree disappears with it. - Return the filtered
QueryRecord[], or nothing to keep every target. - The
contextcarries the populatenodeand its targetcollection. - It covers only
recordandrecordspopulates.
Schema
schema:synced
Runs after the database schema sync commits, carrying the run's GuardReport, a type you can import from ohnejs. Rebuild a search index, refresh a cache, or write an audit trail:
import { hook } from 'ohnejs';
hook('schema:synced', async (report) => {
if (report.deletions.length > 0) {
await notifyOps(`schema sync purged rows:\n${report.deletions.join('\n')}`);
}
});- The report lists only destructive outcomes:
deletionsare rows deleted under force or a migration, andwarningsare orphan rows left in place. - A clean sync that only creates or alters tables reports both as empty, but still fires.
- It fires under both
ohne serve apiandohne sync, but never on a dry run, which commits nothing.
The dashboard
dashboard:menu
Filters the sidebar menu after dashboard.menu resolves, just before GET /dashboard answers. The tree arrives resolved for the viewer: hidden nodes, nodes whose can they lack, and collections they cannot reach are already gone.
Reach for it when a node's visibility needs more than one capability:
import { hook } from 'ohnejs';
import { userCan } from 'ohnejs/auth';
hook('dashboard:menu', (menu, { user }) => {
if (userCan(user, 'audit.read') && userCan(user, 'collection.Users.read')) {
menu.push({ id: 'audit', label: 'Audit log', to: '/audit', icon: 'history' });
}
});- It fires once per discovery read, inside the request context.
- The assistant runs it too, to learn which pages it may open.
- The threaded value is a
DashboardMenuItem[]: the root nodes, each with anid, alabel, and anyto,icon, andchildren. A node withouttois a group. - A label is a message: a key, a
{ key, params }object, or plain text. The dashboard translates it where it renders, so a node you add may carry a key as well. - The tree is built fresh for every call. Mutate it in place, or return a replacement.
- The
contextalso carriescollections: the collections the user can access, as the same read describes them.
dashboard:meta
Extends the discovery payload once everything else resolved, just before GET /dashboard answers. A layer uses it to tell its browser code what the server knows:
import { hook } from 'ohnejs';
hook('dashboard:meta', (meta) => {
Object.assign(meta, { reports: true });
});- It fires once per discovery read, inside the request context, so the viewer is in scope. The
contextcarries theuser. - Put labels in as messages, not translated text. The dashboard translates them where it renders, so the payload stays the same in every language.
- Mutate the object in place; whatever it holds afterwards is the answer.
- Type the key for the browser by augmenting
DashboardMetainohnejs/dashboard, wheredashboardMeta()reads it.
Declaring your own
A layer declares a hook by augmenting the Hooks interface. Name it group:name in kebab-case, and type it as its callback signature:
import { hook } from 'ohnejs';
declare module 'ohnejs' {
interface Hooks {
'report:filename': (name: string, date: Date) => string;
}
}
hook('report:filename', (name, date) => `${date.toISOString().slice(0, 10)}-${name}`);The declaration types both ends: hook checks callbacks against it, and firing it takes the declared parameters.
- A callback that returns a value makes it a filter.
- One that returns
voidmakes it an action.
applyHook fires it: every registered callback runs in order, the first argument threads through their returns, and the final value comes back. An action fires the same way, just without using the result.
import { applyHook } from 'ohnejs';
const name = await applyHook('report:filename', 'report.csv', new Date());
// -> '2026-07-21-report.csv'With no callbacks registered, applyHook returns the first argument unchanged, so firing a hook nobody listens to costs nothing and breaks nothing.