# Extension interfaces

> The interfaces better-supabase is built on, their first-party implementations and how to plug in your own.

Source: https://bettersupabase.com/docs/extending/interfaces

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](/docs/extending/conformance).

| Interface                                               | Built in                                                    | Plug in with                                                            |
| ------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------- |
| [`Executor`](#executor)                                 | `postgrestExecutor`, `postgresExecutor`                     | `betterSupabase.connect(executor)`                                      |
| [`Compiler`](#compiler)                                 | `postgrestCompiler`, `sqlCompiler`                          | your executor                                                           |
| [`CacheAdapter`](#cacheadapter)                         | `nextCache()`, `queryCache(client)`, `memoryCache()`        | `betterSupabase.cache(adapter)`                                         |
| [`EventSink`](#eventsink)                               | `httpSink()`                                                | `forwardMutations(betterSupabase, sink, { source })`                    |
| [`AuthResolver`](#authresolver)                         | Bearer and cookie resolution; `localAuth()` in `/testing`   | `createServer(betterSupabase, { auth: { resolvers } })`                 |
| [`Generator`](#generator)                               | `zod()`, `valibot()`, `jsonSchema()`, `standardSchema()`    | `generators` in the config                                              |
| [`Logger`](#logger)                                     | `consoleLogger`, `silentLogger`                             | `defineSupabase(schema, { logger })`                                    |
| [`QueueBackend`](#queuebackend)                         | `sqlQueueBackend(sql)`, `pgmqPublicBackend(client)`         | `createJobs(backend, queues)`                                           |
| [`SupportSessionStore`](#supportsessionstore)           | `sqlSupportStore(postgres)`                                 | `createServer(betterSupabase, { support: supportSessions({ store }) })` |
| [`NotificationChannel`](#notificationchannel)           | none (bring your email or push provider)                    | `createNotifications({ channels })`                                     |
| [`WebhookSigner`](#webhooksigner)                       | `standardWebhooks()`, `hmacSigner(options)`                 | `createWebhooks({ signer })`                                            |
| [`WebhookTransport`](#webhooktransport)                 | `fetchTransport({ allowUrl })`                              | `createWebhooks({ http })`                                              |
| [`WebhookSecretStore`](#webhooksecretstore)             | `sqlSecretStore(transport)`                                 | `createWebhooks({ secrets })`                                           |
| [`StreamStore`](#streamstore)                           | `postgresStreamStore(options)`, `redisStreamStore(options)` | `teeToStore(store, id, stream)`, `resumeFromStore(store, id)`           |
| [`CredentialProvider`](#credentialprovider)             | `vaultCredentials(options)`, `vercelConnectCredentials()`   | `createServer(betterSupabase, { credentials })`                         |
| [`AuthorizationProvider`](#authorizationprovider)       | none (built by your authorization system)                   | `authorization` in the config                                           |
| [`Authorizer`](#authorizer)                             | `staticAuthorizer()` in `/testing`                          | `createServer(betterSupabase, { authorizer })`                          |
| [`DocumentFormat`](#documentformat)                     | OpenAPI 3.0, 3.1, 3.2 and 3.3-preview                       | `defineApi(...).render(format, options)`                                |
| [`Embedder`](#embedder)                                 | `embedWith(model)`, `supabaseEmbed()`                       | `createKnowledge({ embedder })`, `createMemory({ embedder })`           |
| [`AiTaskRunner`](#aitaskrunner)                         | none (runs your agent)                                      | `createAiTasks({ run })`                                                |
| [`GraphCompiler`](#graphcompiler)                       | none (your engine's compiled form)                          | `createBuilder({ compile })`                                            |
| [`BuilderStarter`](#builderstarter)                     | none (starts a run on your engine)                          | `createBuilder({ start })`                                              |
| [`EveDocumentBackend`](#evedocumentbackend)             | `supabaseDocumentBackend(options)`                          | eve's `fileMemory({ backend })`                                         |
| [`BlockTransportMiddleware`](#blocktransportmiddleware) | none (tracing, timeouts, request ids)                       | `wrapTransport(transport, middleware)`                                  |
| `EnvSource`                                             | `process.env`, `Deno.env.toObject()`                        | `parseEnv(source)`                                                      |
| [`Plugin`](/docs/extending/plugins)                     | timestamps, soft delete, tenant, actor, validation          | `betterSupabase.use(plugin)`                                            |

## Executor [#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).

```ts
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](/docs/repository/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`](/docs/blocks/vector-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](/docs/guides/read-replicas).

## Compiler [#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 [#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.

```ts
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 [#eventsink]

Receives CloudEvents batches. See [CloudEvents](/docs/standards/events) for
`toCloudEvents` and `forwardMutations`.

## QueueBackend [#queuebackend]

Stores and leases jobs for [`createJobs`](/docs/blocks/jobs). 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 [#supportsessionstore]

Records [support sessions](/docs/auth/impersonation). 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 [#notificationchannel]

Sends one [notification](/docs/blocks/notifications) 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 [#webhooksigner]

Adds the signature headers to an [outgoing webhook](/docs/blocks/webhooks-out).
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 [#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 [#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 [#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](/docs/blocks/streams).

## CredentialProvider [#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](/docs/extending/credentials).

## AuthorizationProvider [#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](/docs/extending/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 [#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](/docs/extending/authorizers) describes the contract, and
`testAuthorizer` from `better-supabase/testing` is its conformance block.

## DocumentFormat [#documentformat]

Renders the version-neutral API model that [`defineApi`](/docs/specs)
builds into one document version. The OpenAPI versions are built in;
[Document formats](/docs/extending/document-formats) shows how to write
another, and `testDocumentFormat` from `better-supabase/testing` is its
conformance block.

## Embedder [#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 [#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 [#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 [#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 [#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 [#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](/docs/extending/blocks#transport-middleware) shows it in use.

```ts
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 [#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 [#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 [#generator]

Adds files at codegen time. Return paths relative to the project root; output
must be deterministic so `gen --check` works.

## Logger [#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.

```ts
export const betterSupabase = defineSupabase(schema, { logger: pino() });
```