Extension interfaces
The interfaces better-supabase is built on, their first-party implementations and how to plug in your own.
Every integration point is a small interface. The built-in behavior is one implementation of it, and yours can replace or sit next to it. Each interface has a conformance kit.
| Interface | Built in | Plug in with |
|---|---|---|
Executor | postgrestExecutor, postgresExecutor | betterSupabase.connect(executor) |
Compiler | postgrestCompiler, sqlCompiler | your executor |
CacheAdapter | nextCache(), queryCache(client), memoryCache() | betterSupabase.cache(adapter) |
EventSink | httpSink() | forwardMutations(betterSupabase, sink, { source }) |
AuthResolver | Bearer and cookie resolution; localAuth() in /testing | createServer(betterSupabase, { auth: { resolvers } }) |
Generator | zod(), valibot(), jsonSchema(), standardSchema() | generators in the config |
Logger | consoleLogger, silentLogger | defineSupabase(schema, { logger }) |
QueueBackend | sqlQueueBackend(sql), pgmqPublicBackend(client) | createJobs(backend, queues) |
SupportSessionStore | sqlSupportStore(postgres) | createServer(betterSupabase, { support: supportSessions({ store }) }) |
NotificationChannel | none (bring your email or push provider) | createNotifications({ channels }) |
WebhookSigner | standardWebhooks(), hmacSigner(options) | createWebhooks({ signer }) |
WebhookTransport | fetchTransport({ allowUrl }) | createWebhooks({ http }) |
WebhookSecretStore | sqlSecretStore(transport) | createWebhooks({ secrets }) |
StreamStore | postgresStreamStore(options), redisStreamStore(options) | teeToStore(store, id, stream), resumeFromStore(store, id) |
CredentialProvider | vaultCredentials(options), vercelConnectCredentials() | createServer(betterSupabase, { credentials }) |
AuthorizationProvider | none (built by your authorization system) | authorization in the config |
Authorizer | staticAuthorizer() in /testing | createServer(betterSupabase, { authorizer }) |
DocumentFormat | OpenAPI 3.0, 3.1, 3.2 and 3.3-preview | defineApi(...).render(format, options) |
Embedder | embedWith(model), supabaseEmbed() | createKnowledge({ embedder }), createMemory({ embedder }) |
AiTaskRunner | none (runs your agent) | createAiTasks({ run }) |
GraphCompiler | none (your engine's compiled form) | createBuilder({ compile }) |
BuilderStarter | none (starts a run on your engine) | createBuilder({ start }) |
EveDocumentBackend | supabaseDocumentBackend(options) | eve's fileMemory({ backend }) |
BlockTransportMiddleware | none (tracing, timeouts, request ids) | wrapTransport(transport, middleware) |
EnvSource | process.env, Deno.env.toObject() | parseEnv(source) |
Plugin | timestamps, soft delete, tenant, actor, validation | betterSupabase.use(plugin) |
Executor
Runs IR operations and returns a Result. It never throws for database
errors, returns aborted when the signal is aborted, and keys rows by the
selection's aliases (the configured casing).
import type { Executor } from "better-supabase";
export function kyselyExecutor(db: Kysely<Database>): Executor {
return {
name: "kysely",
async execute(op, { signal, errorMappers }) {
// compile op, run it, map errors with mapDbError(raw, errorMappers)
},
};
}
const db = betterSupabase.connect(kyselyExecutor(kysely), { claims });batch(ops, context) is optional. db.$many hands it every operation that
is ready at once and expects one Result per operation, in order.
postgresExecutor runs them in one transaction. If one fails, it runs each
operation on its own so the others still succeed. Without batch, $many
runs the operations in parallel through execute. testExecutor checks
batch when an executor has it.
rpc(name, args, context) is optional too. context.get is set for
stable functions such as read sets. Send
those as GET, so read replicas can serve them. context.function carries
the function's generated metadata (its arguments, return type and whether it
returns a set) when the schema has it; postgresExecutor reads it to shape
the result like PostgREST.
functionSources: true says the executor honors SelectOp.source: read from
that set-returning function, with its named arguments, instead of the table.
db.$search needs it. testExecutor checks that a
missing source function fails rather than falling back to the table.
betterSupabase.connect(client, { executor }) runs the queries through executor while
db.$client stays the given client. The server uses this to wrap two
PostgREST executors for read replicas.
Compiler
Compiler<TTarget> turns an operation into what a backend runs:
postgrestCompiler.compile(op) returns the PostgREST plan and
sqlCompiler.compile(op) (from better-supabase/postgres) returns
parameterized SQL. Build a new executor on top of either, or write your own
compiler for another query builder.
CacheAdapter
Invalidates cached reads after mutations. betterSupabase.cache(adapter) calls it with the
table key, the primary keys of the changed rows and the tenant, and returns a
function that detaches it.
import type { CacheAdapter } from "better-supabase";
const redis: CacheAdapter = {
name: "redis",
invalidate: ({ table, ids, tenant }) =>
redisClient.del([
`${tenant}:${table}`,
...ids.map((id) => `${tenant}:${table}:${id}`),
]),
};
betterSupabase.cache(redis);createNext attaches nextCache() for you, and invalidateOnMutation(betterSupabase, client) attaches queryCache(client). Adapter errors are logged, never
returned from the mutation.
EventSink
Receives CloudEvents batches. See CloudEvents for
toCloudEvents and forwardMutations.
QueueBackend
Stores and leases jobs for createJobs. It has
apiVersion: 1, sends and reads messages, completes, fails and extends them
by attempt (a stale attempt must get false or null), and optionally
claims due schedules for the drain route, replays dead letters
(replay), and reports queue health (stats, listDead, retryDead). A message read past its max_attempts should be archived as
dead instead of returned. Set leases: false when extend
can't work, so workers skip the heartbeat.
SupportSessionStore
Records support sessions. It has apiVersion: 1
and four methods: start(input) ends the admin's previous session and returns
the new one, get(sessionId, adminId) returns a running session only to its
admin, end(sessionId, endedBy) returns true once and false after, and
list(filter) returns sessions newest first. claims(targetUserId) is
optional and returns the claims the target would get. start should refuse
an admin without permission with an error whose code is 42501.
NotificationChannel
Sends one notification delivery on a channel
other than in-app. It has apiVersion: 1, a name that matches the
delivery's channel and send(message), which gets the recipient's id and
email, the notification and its rendered text. Return { provider, providerMessageId } to store the provider's id, { status: 'skipped' } when
there is nothing to send, and throw to retry later.
WebhookSigner
Adds the signature headers to an outgoing webhook.
It has apiVersion: 1, a name and sign({ id, body, timestamp, secrets }),
which returns the headers. secrets lists every live secret, newest first;
sign with each so receivers keep verifying while a secret rotates. The same
input must give the same headers.
WebhookTransport
Sends one signed webhook request. It has apiVersion: 1, a name and
send({ url, headers, body, signal }), which returns { status, body } for
any HTTP response. Throw WebhookPolicyError for a request that must never
be retried, such as a blocked URL; any other error retries.
WebhookSecretStore
Where destination signing secrets live. It has apiVersion: 1,
secrets(endpointId), which returns the live secrets newest first, and an
optional rotate(endpointId, { overlap, secret }) that returns the new
secret and keeps the previous ones live for overlap.
StreamStore
Durable, resumable output. It has apiVersion: 1, open, append(id, fromIdx, chunks) that skips indexes already stored and reports a cancel,
read(id, fromIdx) that waits for new chunks until the stream closes,
status, close, cancel and purge. See
Durable streams.
CredentialProvider
Turns a credential_ref into a token. It has apiVersion: 1, a name,
capabilities(ref), getToken(ref, { subject, scopes }) and
revoke(ref, { subject }), and optionally startAuthorization,
completeAuthorization and verifyInbound. See
Credentials.
AuthorizationProvider
Hands the SQL modules, the Storage and Realtime policies and doctor to
another authorization system. It has apiVersion: 1 and is plain data in
better-supabase.config.ts: its scopes, the scope tenants are, and SQL
templates such as idsWith and isPlatform that answer permission checks.
Authorization providers describes
every field. testAuthorizationProvider from better-supabase/testing is its
conformance block.
The optional fields each turn on one integration:
| Field | What reads it |
|---|---|
functions.permissionsFor | member_permissions and permission_claims in the access module |
functions.canApprove | decide_ai_tool_approval in the ai-chat module |
approvals.distinctApprover | the same function: the chat's owner can't decide its own tool calls |
functions.*For, memberIds | the modules doctor (BS411) lists when they are missing |
sql: "provider" | bucket and topic access policies, rendered from functions at gen and sql sync |
Authorizer
The runtime decision point for the permission of resources, actions,
route guards and MCP tools. It has apiVersion: 1, a name, key(ref)
and evaluate(request), plus optional forOperation and the batch
evaluations. Requests follow the AuthZEN subject, action, resource and
context model, and anything but a granted outcome refuses.
defineAuthorizer from better-supabase/server builds one and sets
apiVersion. Authorizers describes the contract, and
testAuthorizer from better-supabase/testing is its conformance block.
DocumentFormat
Renders the version-neutral API model that defineApi
builds into one document version. The OpenAPI versions are built in;
Document formats shows how to write
another, and testDocumentFormat from better-supabase/testing is its
conformance block.
Embedder
Turns texts into vectors for the knowledge and memory blocks. It has a
model name and embed(values, { signal }), which returns one vector per
value, all of one length. testEmbedder from better-supabase/testing is its
conformance block.
AiTaskRunner
The function createAiTasks calls for each due run, with the task, the
claimed run and an AbortSignal. It resolves with nothing or with the
chatId the run wrote to, and throws when the signal aborts.
testAiTaskRunner is its conformance block.
GraphCompiler
Turns a builder graph into your engine's form when a version is published.
It returns a JSON value, the same one for the same graph, and leaves the
graph unchanged. testGraphCompiler is its conformance block.
BuilderStarter
Starts a published version on your engine and returns the engine's run id.
A repeated idempotencyKey returns the same run. testBuilderStarter is its
conformance block.
EveDocumentBackend
eve's document store for fileMemory(). read returns the content and
version or null, and write throws when expectedVersion is stale.
testEveDocumentBackend is its conformance block.
BlockTransportMiddleware
Runs around every call a block transport makes, for every block built on it.
It has apiVersion: 1, a name, and call(request, next), where request
holds the schema, fn and args of the call. Pass a changed request to
next to rewrite it, and reject with the error next rejected with, so the
block still maps it to a DbError. testBlockTransportMiddleware from
better-supabase/testing is its conformance kit, and
Extending blocks shows it in use.
import {
defineTransportMiddleware,
wrapTransport,
} from "better-supabase/blocks";
const timed = defineTransportMiddleware({
name: "timing",
async call(request, next) {
const started = performance.now();
try {
return await next(request);
} finally {
metrics.record(request.fn, performance.now() - started);
}
},
});
const transport = wrapTransport(rpcTransport(supabase), timed);Versions of the block injectables
These five carry an optional apiVersion. Omitting it means 1; the blocks
refuse any other value when they are built. The built-in embedders and
supabaseDocumentBackend set it, and the conformance blocks require it, so a
release built for API 1 says so. A function sets it with
Object.assign(fn, { apiVersion: 1 }).
AuthResolver
Resolves credentials the built-in Bearer and cookie resolution doesn't know:
API keys, third-party tokens, signed links. Return undefined when the request
has none of your credentials, and { kind: 'invalid', error } when it has bad
ones, so the request fails with 401 instead of running as anonymous.
Generator
Adds files at codegen time. Return paths relative to the project root; output
must be deterministic so gen --check works.
Logger
Where better-supabase reports errors it swallows on purpose: event handlers,
afterMutation hooks, sinks and cache adapters that throw. Pass a structured
logger (pino, consola) or silentLogger in tests.
export const betterSupabase = defineSupabase(schema, { logger: pino() });Last updated on
Extending blocks
Add your own columns to a block's table, steer its methods with hooks, wrap its transport and add methods, without forking the block.
Authorizers
The runtime authorization decision point that resources, actions, route guards and MCP tools ask for their permission, in the AuthZEN request model, failing closed.