Writing plugins
Plugin API v1, its hooks, and typed repository extensions.
import { column, definePlugin, and } from "better-supabase";
export const onlyPublished = definePlugin({
name: "onlyPublished",
transformQuery(op) {
if (op.kind !== "select" || !("published" in op.table.columns)) return op;
return { ...op, where: and(op.where, column("published", "eq", true)) };
},
});definePlugin fixes apiVersion: 1. use() rejects plugins built for
another version, and installing two plugins with the same name.
Hooks
| Hook | Level | Use it to |
|---|---|---|
context(context, args) | Context | Derive values from the claims once per connect(), such as the tenant |
transformQuery(op, args) | IR | Add filters, rewrite selections |
beforeMutation(op, args) | Mutation | Fill columns, reject writes, change the kind (turn a delete into an update) |
afterMutation(event) | Mutation | Audit, cache invalidation, outbox. Cannot change the result |
wrapExecutor(executor) | Execution | Tracing, retries, caching |
mapError(raw, fallback) | Errors | Map RAISE hint codes or custom SQLSTATEs to DbErrors |
repository(api) | Repository | Add methods to matching tables |
describe(api) | API model | Document the parameters the plugin reads and the columns it manages |
Hooks receive args with the table metadata, the request context, the
per-call options (any argument the repository does not know, like
withDeleted), the clock and the caller's signal when it passed one. A hook
that does I/O (a permission lookup, say) should pass signal on, so a
cancelled request stops it. Throw a DbException to fail the call with a
typed error; nothing is sent. Any other throw from transformQuery or
beforeMutation fails the call too: the result is
an unexpected error with the thrown message, the table, and details
naming the plugin and hook (plugin "audit" beforeMutation threw), and the
error event fires.
defineSupabase().use() and new BetterSupabase(schema, plugins) both
refuse a plugin with another apiVersion and two plugins with the same name.
context runs once per connect() and $with(), in plugin order, and its
result is the db.$context that every other hook, db.$context readers
(jobs, storage) and afterMutation events see. tenant() uses it to set
context.tenant from its own claim paths, so two definitions with different
paths never share a resolved tenant. A context hook that throws is logged
and leaves the context unchanged; $withoutPlugins() skips it.
enforce sets the hook order: first, then pre, then plugins without
enforce, then post; plugins at the same level keep their use() order.
Use first for a plugin that checks the query as the caller wrote it
(rules() does), and pre for one whose rewrite others should see
(softDelete() turns a delete into an update).
A plugin that rewrites a mutation into another kind sets intent on the
operation it returns, and mutation events report it. softDelete() returns
{ kind: 'update', intent: 'softDelete', ... }, which becomes the
row.softdeleted CloudEvent. Without intent, the event reports the kind
that ran.
scopes lists the table flags whose tables a plugin's transformQuery
filters (tenant() declares ['tenant']). Read sets
run as SQL functions without plugins, so defineReadSet warns when a set
reads a table a plugin filters. Declare scopes: [] for a plugin that only
inspects queries; a plugin with transformQuery and no scopes is assumed
to filter every table.
API descriptions
describe(api) tells defineApi what the plugin does to the
tables the API serves, so the OpenAPI document matches the behavior. It
runs once, while the model is built. api.resources lists the served
tables (their metadata and operation names), and every method ignores
tables and columns the API doesn't serve:
| Method | What it changes |
|---|---|
addParameter(table, parameter, operations?) | Adds a query, header or cookie parameter (with a JSON Schema 2020-12 schema) to the table's operations, or to the named ones |
markReadOnly(table, column, description?) | Marks the column readOnly and drops it from the required fields of request bodies; description is used when the column has none |
describeColumn(table, column, patch) | Deep-merges JSON Schema fields into the column's schemas |
api.extensionPrefix is the prefix of generated x- fields, and
api.schema the whole schema metadata.
import { definePlugin } from "better-supabase";
export const region = definePlugin({
name: "region",
describe(api) {
for (const { table } of api.resources) {
if (!("region" in table.columns)) continue;
api.markReadOnly(table.key, "region", "Set from the caller's region.");
api.addParameter(table.key, {
name: "X-Region",
in: "header",
description: "Overrides the caller's region.",
schema: { type: "string" },
});
}
},
});A describe that throws doesn't stop the build: defineApi reports a
plugin-describe-failed warning (see Diagnostics)
and builds the rest of the model. The built-in plugins describe themselves:
timestamps(), softDelete()
and tenant().
Scoping helpers
Filters that should apply to related rows too (tenancy, soft delete,
visibility) should go through scopeOperation. It applies a condition to the
root table, every include and every relation filter, and keeps every()
correct:
import { column, scopeOperation } from 'better-supabase';
transformQuery: (op) =>
scopeOperation(op, (table) =>
table.columns.visibility ? column('visibility', 'eq', 'public') : undefined,
),Column names in the IR are database names; table.columns[app].db maps
app names.
Typed extensions
A plugin can add methods and call options to tables whose generated flags
match. Declare the types as an interface that reads this['M'] (the models)
and this['T'] (the table):
import type { AsyncResult, HasFlag, Plugin, RepositoryExtension } from 'better-supabase';
interface ArchiveExtension extends RepositoryExtension {
readonly methods: HasFlag<this['M'], this['T'], 'softDelete'> extends true
? { readonly archiveAll: () => AsyncResult<{ count: number }> }
: unknown;
readonly findArgs: unknown;
readonly deleteArgs: unknown;
}
export function archive(): Plugin<'archive', ArchiveExtension> {
return definePlugin<'archive', ArchiveExtension>({
name: 'archive',
repository: ({ table, base }) => (table.flags.softDelete ? { archiveAll: () => /* ... */ } : undefined),
});
}findArgs extends the read arguments and deleteArgs the delete arguments.
Each use() intersects the extension into the Db type, so several plugins
compose.
Invariants
- Hooks never see or return app-cased rows in the IR; convert with the table metadata.
afterMutationand event handlers cannot change results: they get a copy of the rows, andtestPluginchecks it. Errors they throw go to theLogger, not the caller.- Hook order depends only on
enforceanduse()order, never on a plugin's name. - Prove a plugin with
testPlugin. It also installs the plugin next totimestamps(),softDelete(),tenant()andactor()in both orders, and fails arepositoryhook that replaces a base method. - A plugin must leave tables without its flag untouched.
Last updated on