Plugins
Three hooks wrapped around every adapter call. The same surface that powers observability, multi-tenancy, and audit. Zero overhead when nothing is registered.
The shape
A plugin is an object with a name and an initialize function. The engine calls initialize once with a PluginContext the plugin uses to attach hooks.
import type { ZanithPlugin } from 'zanith'; const audit: ZanithPlugin = { name: 'audit', initialize(ctx) { ctx.onResult(({ sql, rowCount, elapsedMs }) => { auditLog.write({ sql, rowCount, elapsedMs }); }); },}; await createZanith({ adapter, models, plugins: [audit] });The three hooks
| Hook | Fires | Can it mutate? |
|---|---|---|
onQuery | Before every execute / executeRaw | Yes — return a { sql, params } to replace the query. |
onResult | After a successful query | No — observation only. |
onError | When a query throws | No — observation only. The error is rethrown to the caller after every hook runs. |
Hooks are async. The engine awaits each one in registration order, so a slow hook slows the query. Use fire-and-forget inside the hook body if you need to ship to a remote sink.
Hook payloads
QueryHookInfo
interface QueryHookInfo { sql: string; params: unknown[]; kind: 'execute' | 'executeRaw'; dialect: string; // 'postgres' | 'sqlite' | …}ResultHookInfo
interface ResultHookInfo { sql: string; params: unknown[]; rowCount: number; elapsedMs: number; /** First five rows — for sample logging. */ sampleRows?: unknown[];}ErrorHookInfo
interface ErrorHookInfo { sql: string; params: unknown[]; error: Error; elapsedMs: number;}onQuery — interception
onQuery can return a replacement { sql, params }. Each registered hook sees the previous hook's output, so transformations compose. The multi-tenancy plugins use this to inject tenant_id = $N into every WHERE clause.
const sqlComment: ZanithPlugin = { name: 'sql-comment', initialize(ctx) { ctx.onQuery(({ sql, params }) => ({ sql: `/* ${process.env.SERVICE_NAME ?? 'app'} */ ${sql}`, params, })); },};Registration
import { createZanith, consoleLogger, slowQueryLogger } from 'zanith'; await createZanith({ adapter, models, plugins: [ consoleLogger({ slowMs: 200 }), slowQueryLogger({ thresholdMs: 1000, onSlow: (info) => sentry.captureMessage('slow query', { extra: info }) }), audit, ],});Plugin names must be unique. Registering two plugins with the same name throws at startup.
What plugins do not see
| Operation | Hooks fire? |
|---|---|
adapter.execute(query) | Yes — onQuery, onResult / onError. |
adapter.executeRaw(sql, params) | Yes. |
Inside db.transaction(...) | Yes — every query in the tx runs through hooks. |
adapter.stream(query) | Only onQuery fires. onResult would have to wait for full materialisation, which defeats streaming. |
Migrations (zanith migrate up) | Yes — they run through the same adapter. |
Built on the same surface
Every plugin Zanith ships uses this exact API — see Observability for the four loggers and Multi-tenancyfor the three isolation strategies. There's no privileged interface — your audit plugin and openTelemetryPlugin see the same payloads.