zanith

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.

TS
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

HookFiresCan it mutate?
onQueryBefore every execute / executeRawYes — return a { sql, params } to replace the query.
onResultAfter a successful queryNo — observation only.
onErrorWhen a query throwsNo — 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

TS
interface QueryHookInfo {
sql: string;
params: unknown[];
kind: 'execute' | 'executeRaw';
dialect: string; // 'postgres' | 'sqlite' | …
}

ResultHookInfo

TS
interface ResultHookInfo {
sql: string;
params: unknown[];
rowCount: number;
elapsedMs: number;
/** First five rows for sample logging. */
sampleRows?: unknown[];
}

ErrorHookInfo

TS
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.

TS
const sqlComment: ZanithPlugin = {
name: 'sql-comment',
initialize(ctx) {
ctx.onQuery(({ sql, params }) => ({
sql: `/* ${process.env.SERVICE_NAME ?? 'app'} */ ${sql}`,
params,
}));
},
};

Registration

TS
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

OperationHooks 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.