# Agents

> Build a ToolLoopAgent from a stored agent with its tools, approvals and knowledge search, and check input and output with a moderation middleware.

Source: https://bettersupabase.com/docs/ai-sdk/agents

`better-supabase/ai-sdk/agents` runs an [agent](/docs/blocks/agents) with
the AI SDK. It picks the tools the agent may use, asks for approval where
the tool policies say so, and adds a knowledge search tool for the agent's
scopes.

```bash
pnpm add ai
```

## Runtime [#runtime]

```ts title="app/api/agents/[slug]/route.ts"
import { gateway } from "ai";
import { createAgentRuntime } from "better-supabase/ai-sdk/agents";

const runtime = createAgentRuntime({
  agent,
  model: (modelId) => gateway(modelId ?? "openai/gpt-5-mini"),
  tools: { ...appTools, ...connectorTools },
  policies: await chats.tools.policies(organizationId).orThrow(),
  knowledge: { knowledge, organizationId },
  instructions: "Answer in the user's language.",
});

const result = await runtime.stream({ messages });
```

`createAgentRuntime` returns a `ToolLoopAgent`. Its instructions are the
app's `instructions` followed by the agent's own, and it stops after 20
steps unless you pass `stopWhen`. With `knowledge`, the model gets a
`search_knowledge` tool limited to the agent's knowledge scopes.

| Helper              | Returns                                                                  |
| ------------------- | ------------------------------------------------------------------------ |
| `agentTools`        | The tools the agent lists (all of them when it lists none), minus `deny` |
| `agentToolApproval` | `user-approval` for every tool whose policy is `ask`                     |
| `agentScopes`       | The agent's knowledge scopes, with `agent` resolved to its id            |

An `ask` tool doesn't run until the user approves it: the stream carries a
`tool-approval-request` part, and the [chat route](/docs/ai-sdk/chat) stores
the approval.

## Your own approval check [#your-own-approval-check]

`toolApproval` takes the AI SDK's tool approval function (typed as
`AgentToolApprovalFunction`), for checks such as an authorization call or a
spending limit. It runs first for every call, and the tenant's policies then
apply on top: a tool whose policy is `ask` still needs the user's approval
when your check approves it, and a `deny` from your check always wins. A check
that throws denies the call.

```ts
const runtime = createAgentRuntime({
  agent,
  model,
  tools,
  policies,
  toolApproval: async ({ toolCall }) =>
    (await mayRun(user, toolCall.toolName))
      ? { type: "approved" }
      : { type: "denied", reason: "Not allowed for this user" },
});
```

## Moderation [#moderation]

```ts
import { wrapLanguageModel } from "ai";
import { moderationMiddleware } from "better-supabase/ai-sdk/agents";

const model = wrapLanguageModel({
  model: gateway("openai/gpt-5-mini"),
  middleware: moderationMiddleware({
    chats,
    organizationId,
    chatId,
    check: async (text, stage) => classify(text),
  }),
});
```

`check` gets the last user message (`input`) and the answer (`output`) and
returns a verdict with an `action` and a `category`, or nothing. Every
verdict is recorded in the [AI chat](/docs/blocks/ai-chat) moderation log.
A `block` verdict throws `ModerationBlockedError` before the model runs, or
after it answers; in a stream it ends the stream with an error part.

# Batches

> Start AI SDK batches for a tenant, poll them from a job, and store each request's result in Postgres.

Source: https://bettersupabase.com/docs/ai-sdk/batches

Provider batch APIs run many requests at a lower price, but take minutes to
hours. `aiBatches` from `better-supabase/ai-sdk/batches` starts a batch with
`experimental_startBatch`, records it in the
[AI providers block](/docs/blocks/ai-providers), and a job polls it until
the results are stored in `ai_batch_items`.

```ts title="lib/batches.ts"
import "server-only";

import { aiBatches } from "better-supabase/ai-sdk/batches";

export const batchesFor = (supabase: SupabaseClient) =>
  aiBatches({ providers: providersFor(supabase) });
```

```ts
const batch = await batchesFor(supabase)
  .start(
    { organizationId, userId },
    {
      requests: tickets.map((ticket) => ({
        id: ticket.id,
        type: "text",
        model: "openai/gpt-5-mini",
        prompt: `Summarize: ${ticket.body}`,
      })),
      metadata: { job: "ticket-summaries" },
    },
  )
  .orThrow();
```

`start` calls the provider, then records the batch as the caller, so a
member needs `ai.create` in the organization. `metadata` stays in the
row and never goes to the provider. A provider error is a `network` error
with the hint `AI_BATCH_PROVIDER`.

## The poll job [#the-poll-job]

Schedule `pollJob` every minute through the [jobs block](/docs/blocks/jobs).
Each run claims the batches whose next poll is due, so two workers never
poll the same batch:

```ts
const batches = aiBatches({
  providers: createAiProviders({ transport: service, service, schema: "api" }),
  onDone: (batch) => notify(batch.userId, "Your summaries are ready"),
});

handlers: {
  ai_batch_poll: batches.pollJob();
}
```

| State at the provider                | What the poll does                                                                                                        |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
| `pending`                            | stores the status and counts, and polls again after `pollEvery` seconds                                                   |
| `completed`, `failed` or `cancelled` | stores the status and any error, streams the results there are into `ai_batch_items` in pages of 100, then calls `onDone` |

A poll that fails keeps the batch due and notes the error on the row; the
next run tries again. `onItem(item, batch)` changes what is stored for a
result, for example to move a generated image to Storage and keep its path.

## Reading results [#reading-results]

```ts
const status = await batches.status(batchId).orThrow();
const page = await batches
  .results(batchId, { cursor: lastRequestId, limit: 100 })
  .orThrow();
```

Both read as the caller: a member sees the batches they started, and
`ai.admin` sees every batch in the organization. `cancel(batch)` asks
the provider to cancel and stores the new status.

| Option      | Default                             | Sets                                                      |
| ----------- | ----------------------------------- | --------------------------------------------------------- |
| `providers` | required                            | The providers block, with a service transport for polling |
| `provider`  | the global provider, or the gateway | The batch provider, or a function of the stored reference |
| `pollEvery` | 60                                  | Seconds between two polls of a running batch              |
| `onItem`    | output, usage and error as JSON     | What is stored for one result                             |
| `onDone`    | none                                | Called once a batch's results are stored, or it failed    |

# Response cache

> A wrapLanguageModel middleware that answers repeated model calls from the AI cache block and replays cached streams.

Source: https://bettersupabase.com/docs/ai-sdk/cache

`cacheMiddleware` from `better-supabase/ai-sdk/cache` answers a model call
from the [AI cache block](/docs/blocks/ai-cache) when the same call ran
before. A cached `generateText` result comes back as it was stored; a cached
`streamText` call is replayed through `simulateReadableStream`, so the client
sees the same stream parts in the same order.

```ts title="lib/models.ts"
import "server-only";

import { wrapLanguageModel } from "ai";
import { cacheMiddleware } from "better-supabase/ai-sdk/cache";

import { aiCache } from "./ai-cache";

export const cachedModel = (organizationId: string) =>
  wrapLanguageModel({
    model: gateway("openai/gpt-5-mini"),
    middleware: cacheMiddleware({ cache: aiCache, ttl: 3600, organizationId }),
  });
```

The key is a SHA-256 of the call type (`generate` or `stream`), the tenant,
the provider, the model id and the call options without the abort signal
and the headers. Pass `organizationId` so two tenants never share an entry
and the tenant's deletion removes its entries.

| Option           | Default                          | Sets                                                    |
| ---------------- | -------------------------------- | ------------------------------------------------------- |
| `cache`          | required                         | The AI cache block, created with a service transport    |
| `ttl`            | required                         | Seconds an entry lives, capped by the module's `maxTtl` |
| `organizationId` | none                             | The tenant the entries belong to                        |
| `key`            | provider, model and call options | What the key covers, as any JSON value                  |
| `when`           | every call                       | Return `false` to skip the cache for a call             |
| `replay`         | no delay                         | `initialDelayInMs` and `chunkDelayInMs` for replays     |
| `onError`        | none                             | Called when the cache can't be read or written          |

A call that ends in an error, or a stream with an `error` part, is never
cached. When the cache can't answer, the call goes to the provider and
`onError` gets the `DbError`; the cache never fails a model call.

## What to cache [#what-to-cache]

Cache calls whose answer depends only on the input: a summary of a fixed
document, a classification at `temperature: 0`. Skip calls
with tools that read live data, or a high temperature, through `when`:

```ts
cacheMiddleware({
  cache: aiCache,
  ttl: 86_400,
  when: ({ params }) =>
    (params.temperature ?? 0) === 0 && params.tools === undefined,
});
```

Dates, byte arrays and URLs in a result (a generated file, a response
timestamp) survive the round trip through JSON.

# Assistant

> A chat route over the AI chat block with createAssistant, and useAssistant on the client, with resumable answers, stop, model checks, moderation and quotas.

Source: https://bettersupabase.com/docs/ai-sdk/chat

`createAssistant` is the server half of a chat: it stores the user's
message in the [AI chat block](/docs/blocks/ai-chat), checks the model,
the moderation hook and the quota, claims the chat's run, streams the
answer in the AI SDK's UI message stream protocol and stores the answer
with its usage when it ends. The answer also goes to a
[stream store](/docs/blocks/streams), so a reload or a second tab picks it
up where it is. `useAssistant` is the client half.

## Server [#server]

```ts title="lib/assistant.ts"
import { streamText } from "ai";
import { createAssistant } from "better-supabase/ai-sdk/chat";
import { usageQuota } from "better-supabase/ai-sdk";
import { postgresStreamStore, sqlTransport } from "better-supabase/streams";

export const assistant = createAssistant({
  streams: postgresStreamStore({
    transport: sqlTransport(postgres.asService()),
  }),
  defaultModel: "anthropic/claude-sonnet-4.5",
  quota: usageQuota(usage, "ai.input_tokens"),
  run: ({ model, messages, abortSignal, providerOptions }) =>
    streamText({
      model,
      system: "You are the Acme assistant.",
      messages,
      abortSignal,
      providerOptions,
    }),
  enqueueCostBackfill: (payload) => jobs.enqueue("ai.cost_backfill", payload),
});
```

`run` gets the model the request may use, the history as model messages,
the abort signal `stop` triggers and the [gateway options](/docs/ai-sdk#ai-gateway)
for the user, tenant and chat. Return `streamText(...)` or
`agent.stream(...)`.

Three routes serve it. Each builds the per-request context: the chat block
with the user's transport and a service transport, the user and the tenant.

```ts title="app/api/chat/route.ts"
import { after } from "next/server";

export async function POST(request: Request) {
  const context = await assistantContext(); // { chats, userId, organizationId }
  return assistant.respond(request, { ...context, waitUntil: after });
}
```

```ts title="app/api/chat/[id]/stream/route.ts"
export async function GET(
  request: Request,
  { params }: RouteContext<"/api/chat/[id]/stream">,
) {
  const { id } = await params;
  return assistant.resume(id, await assistantContext(), {
    signal: request.signal,
  });
}
```

```ts title="app/api/chat/[id]/stop/route.ts"
export async function POST(
  _: Request,
  { params }: RouteContext<"/api/chat/[id]/stop">,
) {
  const { id } = await params;
  return assistant.stop(id, await assistantContext());
}
```

`waitUntil` keeps the function running until the answer is stored after the
response is sent: `after` in Next.js, `ctx.waitUntil` on Workers.

## What a request does [#what-a-request-does]

1. Reads `{ id, message, trigger, messageId, model }`. The chat is created
   on first use with the id the client made.
2. Picks the model: the requested one when the tenant's plans allow it
   (`403 AI_MODEL_NOT_ALLOWED` otherwise), then the chat's model, then
   `defaultModel`, then the first allowed model.
3. Runs `moderate` on the user's message, when set. `block` answers
   `403 AI_MODERATION_BLOCKED`; every verdict other than `allow` is recorded.
4. Runs `quota`, when set. An error answers 429 with `Retry-After`.
5. Stores the message (`appendUser`), reads the active branch and repairs
   tool calls that never got a result.
6. Claims the run. While another answer runs it answers
   `409 AI_CHAT_BUSY`.
7. Streams the answer. When it ends, the answer is stored (`complete`, or
   `aborted` after a stop), the run is released with its token usage and
   the gateway's generation id and cost, and `onFinish` runs.

Errors are RFC 9457 problem details responses with a `code` (see [Results](/docs/concepts/results)).

| Option                | Does                                                                    |
| --------------------- | ----------------------------------------------------------------------- |
| `streams`             | The stream store: `postgresStreamStore` or `redisStreamStore`           |
| `run`                 | Starts the generation                                                   |
| `defaultModel`        | The model when the request and the chat name none that is allowed       |
| `quota`               | The quota check, such as `usageQuota(usage, meter)`                     |
| `moderate`            | Checks the user's message: `allow`, `flag`, `redact` or `block`         |
| `enqueueCostBackfill` | Called when the gateway hasn't reported the cost yet                    |
| `onFinish`            | Called after the answer is stored, with its status and usage            |
| `streamTtl`           | How long the stream is kept, seconds or an interval; one day by default |
| `feature`             | The gateway's `feature:` tag, `chat` by default                         |
| `onError`             | The message the client sees for a stream error                          |
| `generateId`          | Message and stream ids, `crypto.randomUUID()` by default                |

## Client [#client]

```tsx title="app/chat/[id]/chat.tsx"
"use client";

import { useAssistant } from "better-supabase/ai-sdk/react";
import type { UIMessage } from "ai";

export function Chat({ id, initial }: { id: string; initial: UIMessage[] }) {
  const { messages, sendMessage, stop, status, regenerate } = useAssistant({
    id,
    messages: initial,
    model: "anthropic/claude-sonnet-4.5",
  });
  // ...
}
```

`useAssistant` is `useChat` with a transport for these routes. It sends
only the new message, since the server keeps the history, reconnects to
`/api/chat/{id}/stream` after a reload (`resume`, on by default) and makes
`stop()` also stop the answer on the server, so it is stored as stopped.
`api`, `body`, `headers`, `onError`, `onFinish` and `throttle` work as in
`useChat`.

Load the history in a Server Component and pass it as `messages`:

```tsx title="app/chat/[id]/page.tsx"
import { toUIMessages } from "better-supabase/ai-sdk";

const path = await aiChat.messages.path(id, { native: true }).orThrow();
return <Chat id={id} initial={toUIMessages(path)} />;
```

The UI message stream protocol version is pinned as
`SPEC_PINS.aiSdkUiMessageStream`; see [Standards](/docs/standards).

## Example [#example]

The Next.js example has an assistant at `/assistant`
(`apps/examples/nextjs/src/features/assistant`). Without `AI_GATEWAY_API_KEY`
it answers with a scripted `MockLanguageModelV4`, so the stored messages,
the stream store and resuming after a reload work on the local stack.

# Embeddings

> Embed knowledge with an AI SDK model or Supabase's built-in model, rerank hits, and give the model a search tool that cites its sources.

Source: https://bettersupabase.com/docs/ai-sdk/embeddings

`better-supabase/ai-sdk/embeddings` connects the [knowledge](/docs/blocks/knowledge)
and [memory](/docs/blocks/memory) blocks to the AI SDK. The blocks take
any `Embedder`; this subpath builds one from an AI SDK model and adds the
tool the model searches with.

```bash
pnpm add ai
```

## Embedders [#embedders]

```ts
import { embedWith, supabaseEmbed } from "better-supabase/ai-sdk/embeddings";

const embedder = embedWith("openai/text-embedding-3-small");
```

`embedWith(model, options)` calls the SDK's `embedMany` with
`maxParallelCalls` (4 by default) and `providerOptions`. A model string
goes through the [AI Gateway](https://vercel.com/docs/ai-gateway). The
embedder's `model` name (`openai/text-embedding-3-small`, or `name` when
you pass one) is stored with each chunk, so `knowledge.reembed` can find
what another model embedded.

`supabaseEmbed()` runs Supabase's built-in `gte-small` model in an Edge
Function, through `Supabase.ai.Session`, with no API key. It returns 384
numbers, so install the modules with `options: { dimensions: 384 }`.

## Search tool [#search-tool]

```ts title="app/api/chat/route.ts"
import { searchTool, toSourceParts } from "better-supabase/ai-sdk/embeddings";

const result = streamText({
  model,
  messages,
  tools: {
    searchKnowledge: searchTool(knowledge, organizationId, {
      scopes: [{ scope: "organization" }, { scope: "chat", id: chatId }],
      onHits: (hits) => sources.push(...toSourceParts(hits)),
    }),
  },
});
```

`searchTool` gives the model a `query` input and returns up to `k` (8)
chunks as `{ results: [{ id, title, content }] }`, with ids in the form
`documentId#index` the model can cite. The search runs as the caller, so
row level security decides what the model sees. `toSourceParts(hits)`
returns one `source-document` part per document, for the assistant
message.

## Reranking [#reranking]

```ts
import { rerankWith } from "better-supabase/ai-sdk/embeddings";

searchTool(knowledge, organizationId, {
  k: 20,
  rerank: rerankWith("cohere/rerank-v3.5", { topN: 5 }),
});
```

`rerankWith` reorders the hits with the SDK's `rerank` and keeps the
`topN` best, with the reranker's score.

# Files

> Read supabase-storage file parts in the AI SDK's experimental_download, store generated files and cache provider file references.

Source: https://bettersupabase.com/docs/ai-sdk/files

`better-supabase/ai-sdk/files` connects the [AI files](/docs/blocks/ai-files)
block to the AI SDK. Messages keep `supabase-storage://` URLs; the adapter
reads them as the caller when a model needs the bytes.

```bash
pnpm add ai
```

## Downloads [#downloads]

```ts title="app/api/chat/route.ts"
import { aiFileDownload } from "better-supabase/ai-sdk/files";
import { streamText } from "ai";

const result = streamText({
  model,
  messages,
  experimental_download: aiFileDownload(files),
});
```

`aiFileDownload(files)` returns the SDK's download function. For a
`supabase-storage://` URL it calls `files.resolve` and `files.read` as the
caller, so the storage policies decide what the model may see and the
`maxBytes` limit holds. A file the caller can't read throws an
`AiFileDownloadError` that carries the `DbError`. URLs the model fetches
itself are left to the model. Other URLs go to the SDK's `createDownload()`,
or fail with `fallback: false`.

## Generated files [#generated-files]

```ts
import { saveGeneratedFiles } from "better-supabase/ai-sdk/files";

const { images } = await generateImage({ model, prompt });
const parts = await saveGeneratedFiles(files, images, {
  organizationId,
  ownerId: userId,
  chatId,
}).orThrow();
```

`saveGeneratedFiles` stores each file as the service role (`files.store`)
and returns file parts with `supabase-storage://` URLs to put in the
assistant message. It takes anything with `uint8Array` and `mediaType`, so
the results of `generateImage`, `generateSpeech` and a model's file output
all fit.

## Provider files [#provider-files]

```ts
import { providerFile } from "better-supabase/ai-sdk/files";

const reference = await providerFile(files, fileId, "openai", async (file) => {
  const uploaded = await openai.files.create({
    file: new File([file.data], file.filename, { type: file.mediaType }),
    purpose: "user_data",
  });
  return { reference: uploaded.id };
}).orThrow();
```

`providerFile` returns the reference a provider's file API gave for the
file, and uploads the file once through your callback when there is none
or it expired. Pass `expiresAt` for providers whose files expire, such as
Gemini's 48 hours, and refresh them from a job with
`files.providerFiles.expiring()`.

# AI SDK

> Convert between AI SDK UI messages and the canonical message format, attribute spend in the AI Gateway, and keep the model catalog and costs current.

Source: https://bettersupabase.com/docs/ai-sdk

`better-supabase/ai-sdk` connects the [AI SDK](https://ai-sdk.dev) to the
[AI chat block](/docs/blocks/ai-chat). The block stores messages in its own
format and never imports an SDK; this adapter converts AI SDK messages to
that format and back, and adds the AI Gateway helpers the assistant uses.
It owns no tables.

```bash
pnpm add ai @ai-sdk/react
```

`ai` (7), `@ai-sdk/react` (4) and `@ai-sdk/mcp` (2) are optional peers: install them only in
the apps that use these subpaths.

| Subpath                                              | What it gives you                                                                                                                        |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `better-supabase/ai-sdk`                             | Message converters and the AI Gateway helpers on this page, [tenant keys](/docs/ai-sdk/providers) and [metering](/docs/ai-sdk/telemetry) |
| `better-supabase/ai-sdk/chat`                        | [`createAssistant`](/docs/ai-sdk/chat): the chat route over the block                                                                    |
| `better-supabase/ai-sdk/files`                       | [`aiFileDownload`](/docs/ai-sdk/files): file parts read as the caller, generated files and provider file references                      |
| `better-supabase/ai-sdk/embeddings`                  | [`embedWith`](/docs/ai-sdk/embeddings): embedders, a reranker and the knowledge search tool                                              |
| `better-supabase/ai-sdk/memory`                      | [`memoryTool`](/docs/ai-sdk/memory): the memory tool, recall, core memory in the instructions and fact extraction                        |
| `better-supabase/ai-sdk/agents`                      | [`createAgentRuntime`](/docs/ai-sdk/agents): a `ToolLoopAgent` from an agent, tool approvals and moderation                              |
| `better-supabase/ai-sdk/mcp`                         | [`connectTools`](/docs/ai-sdk/mcp): OAuth for MCP servers with tokens in Vault, sessions and approved tools                              |
| `better-supabase/ai-sdk/cache`                       | [`cacheMiddleware`](/docs/ai-sdk/cache): repeated model calls answered from the AI cache block                                           |
| `better-supabase/ai-sdk/batches`                     | [`aiBatches`](/docs/ai-sdk/batches): provider batches recorded, polled and stored per tenant                                             |
| `better-supabase/ai-sdk/react`                       | [`useAssistant`](/docs/ai-sdk/chat#client): `useChat` wired to that route                                                                |
| `better-supabase/ai-sdk/workflow`, `/workflow/react` | [`durableChat`](/docs/ai-sdk/workflow): answers that run as Workflow SDK workflows, and `useDurableAssistant`                            |

## Messages [#messages]

```ts
import { fromUIMessage, toUIMessages } from "better-supabase/ai-sdk";

const stored = fromUIMessage(uiMessage); // an AiMessage for the block
const path = await aiChat.messages.path(chatId, { native: true }).orThrow();
const messages = toUIMessages(path); // UIMessage[] for useChat
```

`toUIMessage` returns the stored AI SDK copy (`native`) when the adapter
wrote the message, so nothing is lost on the round trip. A message another
SDK wrote is converted from its parts:

| Canonical part                 | UI message part                         |
| ------------------------------ | --------------------------------------- |
| `text`, `reasoning`, `file`    | the same part                           |
| `source` (`url` or `document`) | `source-url` or `source-document`       |
| `tool-call`, `tool-result`     | one `tool-<name>` part with its `state` |
| `tool-approval`                | the `approval` of that tool part        |
| `step`                         | `step-start`                            |
| `data`                         | `data-<name>`                           |

A `tool` message's results join the tool parts of the assistant message
before it, the way `useChat` expects them. A UI part with no canonical
counterpart is stored as a `data` part (`ui.custom` or `ui.reasoning-file`)
and comes back unchanged.

## AI Gateway [#ai-gateway]

Models are `provider/model` strings for the
[AI Gateway](https://vercel.com/docs/ai-gateway). `gatewayOptions` attributes
spend to the user, the tenant and the chat:

```ts
import { streamText } from "ai";
import { gatewayOptions, usageOf } from "better-supabase/ai-sdk";

const result = streamText({
  model: "anthropic/claude-sonnet-4.5",
  messages,
  providerOptions: gatewayOptions({
    userId,
    organizationId,
    chatId,
    feature: "chat",
  }),
});

const usage = usageOf({
  totalUsage: await result.totalUsage,
  providerMetadata: await result.providerMetadata,
});
```

`usageOf` returns the token counts, the gateway's `generationId` and the
cost in micro-dollars when the gateway reported it. `usageEntries(usage)`
turns it into entries for the [usage block](/docs/blocks/usage)
(`ai.input_tokens` and `ai.output_tokens` by default), and
`usageQuota(usage, meter)` is the quota check `createAssistant` takes: it
returns a `quota_exceeded` error with `retryAfter` when the meter is used up.
`problem429(error)` is the RFC 9457 response for it, with `Retry-After`.

## Jobs [#jobs]

Two [job](/docs/blocks/jobs) handlers keep the block current:

```ts title="jobs.ts"
import { gateway } from "ai";
import { costBackfill, modelCatalogRefresh } from "better-supabase/ai-sdk";

export const handlers = {
  "ai.cost_backfill": costBackfill({
    chats: aiChat,
    gateway,
    usage,
    meter: "ai.cost",
  }),
  "ai.model_catalog": modelCatalogRefresh({
    chats: aiChat,
    gateway,
    prune: true,
  }),
};
```

`costBackfill` reads the exact cost from `gateway.getGenerationInfo` once
the gateway has it, writes it to the run (`runs.setCost`) and, with
`usage`, records it once per generation. `createAssistant` enqueues it
when the answer arrives without a cost. `modelCatalogRefresh` copies
`gateway.getAvailableModels()` into `ai_model_catalog`, language models by
default, with `plans` deciding which entitlements may use each model. Run it
on a schedule.

# MCP connectors

> Authorize a user with an MCP server through OAuth with dynamic client registration in Vault, connect with the stored session and give the model the server's tools.

Source: https://bettersupabase.com/docs/ai-sdk/mcp

`better-supabase/ai-sdk/mcp` connects the [connectors](/docs/blocks/connectors)
block to MCP servers through `@ai-sdk/mcp`. It runs the OAuth flow for a
user, keeps the tokens in Vault, reuses the MCP session of a chat and only
returns tools from a tool list an admin approved.

```bash
pnpm add ai @ai-sdk/mcp
```

## Authorize a user [#authorize-a-user]

```ts title="app/connectors/[id]/connect/route.ts"
import { authorizeConnector } from "better-supabase/ai-sdk/mcp";

const result = await authorizeConnector({
  connectors,
  vault,
  server,
  userId,
  redirectUrl: `${origin}/connectors/callback`,
}).orThrow();
if (result.url) return Response.redirect(result.url);
```

```ts title="app/connectors/callback/route.ts"
import { completeConnector } from "better-supabase/ai-sdk/mcp";

await completeConnector({
  connectors,
  vault,
  server,
  userId,
  redirectUrl: `${origin}/connectors/callback`,
  callback: request.url,
}).orThrow();
```

`authorizeConnector` registers the app with the server's authorization
server when it has no client yet (dynamic client registration) and returns
the URL to send the user to, or no URL when the stored tokens still work.
`completeConnector` checks the state, exchanges the code, then records the
grant. `vaultOAuthProvider` is the `OAuthClientProvider`
behind both: the client information goes in an app secret
`mcp-client:<serverId>` and the user's tokens and PKCE verifier in a user
secret under `mcpOAuthRef(serverId)`.

## Connect and get tools [#connect-and-get-tools]

```ts
import { connectAll } from "better-supabase/ai-sdk/mcp";

const servers = await connectors.servers.list(organizationId).orThrow();
const { tools, skipped, close } = await connectAll(servers, {
  connectors,
  userId,
  vault,
  credentials,
  chatKey: chatId,
});
try {
  return streamText({ model, messages, tools });
} finally {
  await close();
}
```

`connectTools` connects to one server and `connectAll` to many, naming each
tool after its server (`github_create_issue`). A server's auth type sets
the credential:

| Auth     | Sends                                                              |
| -------- | ------------------------------------------------------------------ |
| `none`   | Nothing                                                            |
| `header` | The headers of the server's `credential_ref`, resolved for the app |
| `oauth`  | The user's Vault tokens, or their grant through another provider   |

With `chatKey`, the MCP session id and initialize result are stored per
chat and reused on the next request. A server that is disabled, has no
grant for the user or can't be reached fails with the hint
`CONNECTOR_DISABLED`, `CONNECTOR_NOT_AUTHORIZED` or `CONNECTOR_UNREACHABLE`;
`connectAll` lists those in `skipped` and keeps the rest. A tool list that
changed returns `CONNECTOR_TOOLS_CHANGED` until an admin approves it.

Pass `apps: true` to split out MCP Apps tools into `appTools`, and `elicit`
to answer a server's elicitation requests; without it they are declined.

## Supabase's MCP server [#supabases-mcp-server]

Supabase's [MCP server](https://supabase.com/docs/guides/getting-started/mcp)
gives an agent project tools: list tables, run SQL, read the logs and search
the Supabase docs. `supabaseMcp` returns the URL to register as a connector
server and the tool schemas from `@supabase/mcp-server-supabase`, so
`connectTools` types each tool's input and output:

```bash
pnpm add @supabase/mcp-server-supabase
```

```ts
import { connectTools, supabaseMcp } from "better-supabase/ai-sdk/mcp";

const supabase = await supabaseMcp({
  projectRef: "abcdefghijklmnopqrst",
  features: ["database", "docs"],
});
// supabase.url is
// https://mcp.supabase.com/mcp?project_ref=abcdefghijklmnopqrst&read_only=true&features=database%2Cdocs

const { tools, close } = await connectTools({
  connectors,
  server, // a connector server registered with supabase.url
  userId,
  vault,
  schemas: supabase.schemas,
}).orThrow();
```

`supabaseMcp` is read-only unless you pass `readOnly: false`: the URL sets
`read_only=true`, so SQL runs read-only, and the schemas leave out the tools
that write, such as `apply_migration`.
`connectTools` returns only the tools the schemas name, and the fingerprint
an admin approves still covers every tool the server lists. Pass
`url: "http://localhost:54321/mcp"` for the server of the local Supabase
stack.

The hosted server signs the user in with their Supabase account through
OAuth, so register it with the `oauth` auth type and send the user through
`authorizeConnector` first. To serve it from your own app with a Management
API token you hold, see
[Supabase's MCP server](/docs/frameworks/mcp#supabases-mcp-server) on the MCP
page.

Supabase's server and better-supabase's MCP tools answer different needs.
Supabase's tools administer a project (SQL, migrations, logs, branches) for
the people who run it. [`createMcp`](/docs/frameworks/mcp) tools are typed,
RLS-scoped tools over your tables for your app's own users.

# Memory

> Give the model the memory tool, Anthropic's built-in memory tool, a recall tool and its core memory, and extract facts from a conversation in a job.

Source: https://bettersupabase.com/docs/ai-sdk/memory

`better-supabase/ai-sdk/memory` connects the [memory](/docs/blocks/memory)
block to the AI SDK. The block stores what the model remembers; these
helpers let the model read and edit it.

```bash
pnpm add ai
```

## Tools and instructions [#tools-and-instructions]

```ts title="app/api/chat/route.ts"
import {
  memoryTool,
  recallTool,
  withMemory,
} from "better-supabase/ai-sdk/memory";

const result = streamText({
  model,
  instructions: await withMemory(INSTRUCTIONS, memory, organizationId),
  messages,
  tools: {
    memory: memoryTool(memory, organizationId),
    recall: recallTool(memory, organizationId),
  },
});
```

`memoryTool` takes the commands of Anthropic's memory tool (`view`,
`create`, `str_replace`, `insert`, `delete`, `rename`) and works with any
model. A failed command comes back to the model as text that starts with
`Error:`, so it can try again instead of ending the run. `recallTool`
searches archival memory and returns up to `k` (5) facts.

`withMemory` appends the caller's core memory files to the instructions
and labels them as notes, not instructions. Without memory, or when it
can't be read, the instructions come back unchanged.

Pass a namespace as the last argument to keep memory per agent or chat:
`memoryTool(memory, organizationId, { scope: "agent", agentId })`.

## Anthropic's memory tool [#anthropics-memory-tool]

```ts
import { anthropic } from "@ai-sdk/anthropic";
import { anthropicMemory } from "better-supabase/ai-sdk/memory";

const tools = {
  memory: anthropicMemory(anthropic.tools, memory, organizationId),
};
```

With Claude models, `anthropicMemory` backs the provider's built-in
`memory_20250818` tool with the block, so the model uses the memory
format it was trained on. The version is pinned in `SPEC_PINS`.

## Extracting facts [#extracting-facts]

```ts title="lib/jobs.ts"
import { extractMemories } from "better-supabase/ai-sdk/memory";

export const handlers = {
  extract_memories: extractMemories({
    model: "openai/gpt-5-mini",
    memory: serviceMemory,
  }),
};
```

`extractMemories` is a [jobs](/docs/blocks/jobs) handler. Enqueue it after
a conversation with `organization_id`, `owner_id` and the conversation
`text`; it asks the model for lasting facts about the user and saves up
to `maxFacts` (10) that aren't already remembered. The memory block it
gets needs a service transport, because it writes for the user.

# Tenant keys and sandboxes

> Send each tenant's own provider keys to the AI Gateway as BYOK, and keep the sandbox registry current while a sandbox runs.

Source: https://bettersupabase.com/docs/ai-sdk/providers

Two helpers in `better-supabase/ai-sdk` connect the blocks to model calls:
one turns a tenant's saved keys in the
[AI providers block](/docs/blocks/ai-providers) into the AI Gateway's `byok`
option, the other marks a sandbox in the
[AI chat block](/docs/blocks/ai-chat#sandboxes) used so the idle-stop job
leaves it running.

## Bring your own key [#bring-your-own-key]

`tenantGatewayOptions(providers, context, extra)` returns the same
`providerOptions` as [`gatewayOptions`](/docs/ai-sdk#ai-gateway), with the
tenant's enabled keys under `byok`. The keys are read through the service
role for this request only and never stored outside the credential
provider:

```ts
import { streamText } from "ai";
import { tenantGatewayOptions } from "better-supabase/ai-sdk";

const providerOptions = await tenantGatewayOptions(providers, {
  userId,
  organizationId,
  chatId,
  feature: "chat",
}).orThrow();

const result = streamText({
  model: "anthropic/claude-sonnet-4.5",
  messages,
  providerOptions,
});
```

The gateway tries the tenant's key first and falls back to the app's
credentials when it fails. A tenant without keys gets plain
`gatewayOptions`, so the same code serves both.

With [`createAssistant`](/docs/ai-sdk/chat), merge the keys into the
options `run` receives:

```ts
run: async ({ model, messages, providerOptions, context }) => {
  const keys = await providers.keys.resolve(context.organizationId).orThrow();
  return streamText({
    model,
    messages,
    providerOptions: {
      ...providerOptions,
      gateway: { ...providerOptions.gateway, ...byokOptions(keys) },
    },
  });
},
```

`byokOptions(keys)` maps each key to `{ apiKey: token, ...settings }`
under its provider, in order, so a tenant can save a second key as a
fallback. A provider whose credential field isn't `apiKey` names it in the
key's `settings.credentialField`; other settings, such as a region, go to
the gateway as they are.

| Spend                       | Who pays | `spendReconciliation` with |
| --------------------------- | -------- | -------------------------- |
| calls with the tenant's key | tenant   | `credentialType: "byok"`   |
| calls with the app's key    | the app  | `credentialType: "system"` |

## Sandboxes [#sandboxes]

`trackedSandbox(session, { sandboxes, id })` wraps an AI SDK
`experimental_sandbox` session. Every method call marks the registry row
used, at most every 30 seconds (`every`), so a sandbox in use is never
stopped as idle:

```ts
import { trackedSandbox } from "better-supabase/ai-sdk";
import { createAiChat } from "better-supabase/blocks/ai-chat";

const { sandboxes } = createAiChat({ transport, service: serviceTransport });
const row = await sandboxes
  .register(organizationId, { provider: "vercel", sandboxId, chatId, userId })
  .orThrow();
const sandbox = trackedSandbox(session, {
  sandboxes,
  id: row.id,
});
```

Before starting a sandbox for a chat, `sandboxes.forChat(chatId, "vercel")`
returns the one still running, so the next message reuses it. The
[idle-stop job](/docs/blocks/ai-chat#sandboxes) stops the rest.

# Metering

> Meter every model call on the tenant's usage meters through registerTelemetry, and reconcile each tenant's AI Gateway spend nightly.

Source: https://bettersupabase.com/docs/ai-sdk/telemetry

`meterTelemetry` from `better-supabase/ai-sdk` is an AI SDK telemetry
integration that records the tokens of every model call on the
[usage block](/docs/blocks/usage). Register it once, and calls made
anywhere in the app (the assistant, agents, a job, a batch) are metered on
the tenant without passing usage around.

```ts title="instrumentation.ts"
import { registerTelemetry } from "ai";
import { meterTelemetry } from "better-supabase/ai-sdk";

import { serviceUsage } from "@/lib/usage";

export function register() {
  registerTelemetry(meterTelemetry({ usage: serviceUsage }));
}
```

| Call                     | Meter                 | Quantity         |
| ------------------------ | --------------------- | ---------------- |
| each language model call | `ai.input_tokens`     | input tokens     |
| each language model call | `ai.output_tokens`    | output tokens    |
| `embed`, `embedMany`     | `ai.embedding_tokens` | embedding tokens |
| `rerank`                 | `ai.rerank_calls`     | 1                |

An agent with several steps records each step. The tenant comes from the
`org:` tag that [`gatewayOptions`](/docs/ai-sdk#ai-gateway) puts in
`providerOptions.gateway.tags`, then from `runtimeContext.organizationId`;
a call without either isn't metered. Pass `tenant(event)` to read it from
somewhere else, and `meters` to rename the meters.

Each row carries an idempotency key per call and step, so a retried event
is counted once. Don't also record the same tokens with `usageEntries`, or
they count twice. A failed write goes to `onError`; it never fails the
model call.

## Nightly spend reconciliation [#nightly-spend-reconciliation]

The per-call cost in the usage block comes from each response. The AI
Gateway's spend report is the figure you are billed, and the two drift when
a response arrives without a cost or a call fails after it was charged.
`spendReconciliation` is a job handler that reads yesterday's report grouped
by tag and records each tenant's cost, in micro-dollars, on its own meter:

```ts title="jobs.ts"
import { gateway } from "ai";
import { spendReconciliation } from "better-supabase/ai-sdk";

export const handlers = {
  "ai.spend_reconcile": spendReconciliation({
    gateway,
    usage: serviceUsage,
    credentialType: "system",
  }),
};
```

Schedule it once a day after midnight UTC. The payload can name a `day`
(`YYYY-MM-DD`) to reconcile another one. Each tenant's day is recorded once
on `ai.gateway_cost` (set `meter` to change it), so running the job twice
changes nothing. `credentialType: "system"` counts only the spend the app
pays, leaving out calls made with a tenant's own key. Compare
`ai.gateway_cost` with the per-call cost meter to find the drift.

# Durable chat

> Answers that run as Workflow SDK workflows with durableChat and durableTurn, tool approvals that wait for hours, stop through a hook, reconnects by chunk index and progress in ai_run_steps.

Source: https://bettersupabase.com/docs/ai-sdk/workflow

`better-supabase/ai-sdk/workflow` runs an answer as a
[Workflow SDK](https://useworkflow.dev) workflow instead of inside the
request. The answer keeps going when the user closes the tab or the
function is redeployed, a tool call can wait hours for an approval, and a
research agent can record its progress for an activity page. It uses the
same [AI chat block](/docs/blocks/ai-chat) tables as
[`createAssistant`](/docs/ai-sdk/chat), so a chat can mix both kinds of
answers, and it owns no tables.

```bash
pnpm add ai @ai-sdk/react @ai-sdk/workflow workflow
```

`@ai-sdk/workflow` and `workflow` are optional peers. The World that stores
the runs can be the one on Supabase
([`better-supabase/workflow-sdk/world`](/docs/blocks/workflow-sdk)) or any
other World.

| Subpath                                 | What it gives you                                                                 |
| --------------------------------------- | --------------------------------------------------------------------------------- |
| `better-supabase/ai-sdk/workflow`       | `durableChat` for the routes, `durableTurn` for the workflow and the turn's steps |
| `better-supabase/ai-sdk/workflow/react` | `useDurableAssistant` and `durableTransport` for the client                       |

## The workflow [#the-workflow]

A turn is a `"use workflow"` function that calls `durableTurn` with the
Workflow SDK's functions, the agent and one `"use step"` function for the
database work. Steps run outside any request, so they use the service role.

```ts title="lib/assistant/turn.ts"
import { WorkflowAgent } from "@ai-sdk/workflow";
import {
  type DurableStepInput,
  type DurableStepName,
  type DurableStepOutput,
  type DurableTurnInput,
  durableTurn,
  runDurableStep,
} from "better-supabase/ai-sdk/workflow";
import { createHook, getWorkflowMetadata, getWritable } from "workflow";

export async function chatStep<K extends DurableStepName>(
  name: K,
  input: DurableStepInput<K>,
): Promise<DurableStepOutput<K>> {
  "use step";
  return runDurableStep(serviceAiChat(), name, input);
}

export async function assistantTurn(input: DurableTurnInput) {
  "use workflow";
  return durableTurn(input, {
    runId: getWorkflowMetadata().workflowRunId,
    createHook,
    getWritable,
    step: chatStep,
    agent: (args) =>
      new WorkflowAgent({
        model: args.model,
        instructions: "You are the Acme assistant.",
        tools,
      }).stream(args),
  });
}
```

The model is a gateway string such as `"openai/gpt-5-mini"`: the agent
passes it to a step, so it has to be serializable. `args` also carries the
chat id and the segment's `ai_runs` id for tools that record progress.

`durableTurn` runs the agent and saves the answer after each pass, always
under the same message id, so a retried step updates the message instead
of adding one. When the agent asks for approvals it stores them in
`ai_tool_approvals`, releases the run and waits on a hook for as long as the
decisions take. The decisions are read back from the database, not from the
hook payload. Each pass writes its own stream segment and `ai_runs` row.

The four steps are `save`, `approvals`, `decisions` and `release`.
`runDurableStep(chats, name, input, options)` runs one of them;
`durableSteps(chats)` returns them as an object, and `saveMessagesStep` is
the save on its own. Pass `enqueueCostBackfill` in `options` to queue the
[cost backfill](/docs/ai-sdk#jobs) when the gateway hasn't reported the cost
yet.

## Routes [#routes]

`durableChat` is the server half. Its `assistant` is a
[`createAssistant`](/docs/ai-sdk/chat) instance, whose model, moderation and
quota checks run before the workflow starts.

```ts title="lib/assistant/durable.ts"
import { durableChat } from "better-supabase/ai-sdk/workflow";

export const durable = durableChat({
  assistant,
  workflow: assistantTurn,
  streams: postgresStreamStore({ transport: serviceTransport }),
});

// Per request: the assistant's context. Its chats client carries the runs.
const context = await assistantContext();
```

| Route                             | Calls                                 | Does                                                                     |
| --------------------------------- | ------------------------------------- | ------------------------------------------------------------------------ |
| `POST /api/chat`                  | `respond(request, context)`           | Starts the turn, or continues it with `{ id, approvals }`                |
| `GET /api/chat/[id]/stream`       | `resume(id, context, { startIndex })` | Streams the running segment from UI chunk `startIndex`, or answers 204   |
| `POST /api/chat/[id]/stop`        | `stop(id, context)`                   | Stops the turn through its stop hook, or cancels the run when it is gone |
| An approval inbox (server action) | `decide(id, decisions, context)`      | Decides approvals and continues the turn; answers `{ continued }` JSON   |

Every streaming response has the `x-workflow-run-id` header
(`WORKFLOW_RUN_ID_HEADER`). The stream is the World's stream for the
segment, converted with `normalizeUIMessageStreamParts`; a copy goes to the
[stream store](/docs/blocks/streams), which answers a reconnect when the
World's stream can't be read. `startIndex` counts UI chunks from the start
of the segment; a negative index isn't supported.

Errors are RFC 9457 problem details with a `code`:

| Code                   | When                                                       |
| ---------------------- | ---------------------------------------------------------- |
| `AI_CHAT_BUSY`         | Another answer of the chat is running                      |
| `AI_CHAT_BAD_REQUEST`  | The body has no chat id or malformed approvals             |
| `AI_CHAT_NOT_DURABLE`  | The approvals belong to an answer that isn't a workflow    |
| `AI_CHAT_NOT_WAITING`  | The turn already ended                                     |
| `AI_APPROVALS_PENDING` | Other approvals of the answer still wait for a decision    |
| `AI_CHAT_RUN_FAILED`   | The workflow could not start; the run is released as error |

## Client [#client]

```tsx title="app/chat/[id]/chat.tsx"
"use client";

import { useDurableAssistant } from "better-supabase/ai-sdk/workflow/react";

export function Chat({ id, initial, streaming }: Props) {
  const chat = useDurableAssistant({
    id,
    messages: initial,
    resume: streaming,
  });
  // chat.addToolApprovalResponse({ id: approvalId, approved: true })
}
```

`useDurableAssistant` is [`useAssistant`](/docs/ai-sdk/chat#client) on
`durableTransport`, which reconnects with the chunk index the client already
has. Once every approval of an answer is answered, it sends the decisions
and streams the rest. `resume` defaults to `false`: pass
`chat.activeStreamId !== undefined` from the chat row, read in a Server
Component, so a reload picks up a running answer without a request for
every chat that has none.

## Progress and approvals [#progress-and-approvals]

`runs` on the [chat client](/docs/blocks/ai-chat#durable-runs) reads the
runs and their steps. A tool records a step by key, and recording the same key again
updates it:

```ts
await runs.steps.record(args.runId, {
  key: toolCallId,
  label: question,
  status: "done",
  detail: { answer },
});
```

`runs.list()` returns the caller's runs, `runs.steps.list(runId)` a run's
steps and `runs.pendingApprovals()` the caller's undecided approvals across
chats, which is what an approval inbox shows. See
[AI chat](/docs/blocks/ai-chat#durable-runs) for the tables.

## Example [#example]

The Next.js example's assistant (`apps/examples/nextjs/src/features/assistant`)
has three modes: Standard on `createAssistant`, Durable on `durableChat`,
and Research, an agent that outlines sub-questions, answers each in a
subagent step, records its progress in `ai_run_steps` and asks for approval
before it sends its report. `/assistant/activity` lists the runs with their
steps and the approvals waiting for the user. Durable modes need
`AI_GATEWAY_API_KEY`, since the scripted model of the demo mode can't cross
a step boundary.

# Account deletion

> Delete or suspend a user, end their sessions, remove their Storage objects, and keep foreign keys from blocking a delete.

Source: https://bettersupabase.com/docs/auth/account-deletion

Deleting an account touches three systems: Storage objects the user
uploaded, the Auth user, and every row that points at `auth.users`.
`deleteAccount` deletes the Auth user first, which applies the foreign keys,
then removes the objects, and returns a `Result`, like every repository
call.

```ts title="src/features/user/user-actions.ts"
"use server";

import { dbError, err } from "better-supabase";

import { avatars, documents } from "@/lib/buckets";
import { bs } from "@/lib/supabase/server";

export const deleteMyAccount = bs.action(
  { aal: "aal2" },
  async (_input, { auth }) => {
    if (auth.kind !== "user") {
      return err(dbError("unauthorized", "Sign in to delete your account"));
    }
    return bs.deleteAccount(auth.user.id, {
      buckets: [avatars, documents],
      cascades: ["notifications", "memberships"],
    });
  },
);
```

```ts
// { ok: true, data: { userId, removed: { avatars: 1, documents: 14 } } }
```

It is on every server (`createServer`, `createNext`, Hono, oRPC, edge, MCP) and
needs `SUPABASE_SECRET_KEY`. Without one it returns an `unexpected` error.

Apps on the `@supabase/server` pipeline, without a better-supabase server,
import it from `better-supabase/server` and pass their service-role client:

```ts
import { deleteAccount } from "better-supabase/server";

pipeline(
  [withSupabase({ auth: "user" }), withBetterSupabase(betterSupabase)],
  (req, ctx) =>
    deleteAccount(betterSupabase, ctx.supabaseAdmin, ctx.userClaims!.id, {
      buckets: [avatars, documents],
    }),
);
```

## Ended sessions [#ended-sessions]

better-supabase verifies access tokens locally, so a token stays valid until
it expires (an hour by default), even after the user signs out everywhere or
an admin ends the session. For actions you can't undo, also check that the
session still exists. `checkSession` reads `auth.sessions` with one query and
returns an `unauthorized` error with code `SESSION_REVOKED` when it is gone:

```ts
import { checkSession } from "better-supabase/server";

import { postgres } from "@/lib/postgres";

const ended = await checkSession(postgres.admin, auth);
if (ended) return err(ended);
```

The client needs to read `auth.sessions`, which the Data API roles can't, so
pass `createPostgres().admin` or another connection as `postgres`. Service and
anonymous callers pass through. Nothing calls it on its own: every other
request keeps verifying the token without a database or Auth round trip.

To enforce the same check in the database, add the `sessions` SQL module
(`better-supabase sql add sessions`) and a restrictive policy on the tables
that matter. `session_active()` is false once the session is gone or
expired, or the user is banned or deleted. Wrapped in `(select ...)`, it costs
one indexed lookup per statement:

```sql
create policy session_required on public.invoices as restrictive
  for all to authenticated
  using ((select better_supabase.session_active()))
  with check ((select better_supabase.session_active()));
```

Tokens without a `session_id` claim (signed by your app) and support tokens
(with an `act` claim, which the support block ends) pass.

The module also adds `better_supabase.last_sign_ins(uuid[])`, which returns
`(user_id, last_sign_in_at)` for many users in one query. `auth.users` isn't
readable through the Data API, so only the service role may call it. From
TypeScript, `lastSignIns` reads a page of users at once and returns a `Map`
keyed by user id, with `lastSignInAt` as a `Temporal.Instant` (or `null` for a
user who never signed in). Users that don't exist are missing from the map:

```ts
import { lastSignIns } from "better-supabase/server";

const signIns = await lastSignIns(
  postgres.admin,
  users.map((user) => user.id),
);
const lastSeen = signIns.orThrow().get(userId)?.lastSignInAt;
```

To put that policy on every table, set `options.policies`. `sql sync` reads
the `create table` and `drop table` statements in your declarative schema
files (your migrations only when the project has none) and writes
one `bs_session_active` policy per table in `schemas`, except the
`exclude` globs. Run it again after adding a table:

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      sessions: {
        options: { policies: true, exclude: ["public.pricing_plans"] },
      },
    },
  },
});
```

Doctor reports every RLS table in `schemas` without a restrictive
`session_active()` policy as [BS320](/docs/cli/doctor#bs320), skipping the
`exclude` globs and the tables SQL modules own.

## Suspending an account [#suspending-an-account]

Banning a user in Supabase Auth refuses sign-in and refresh, but the user's
`auth.sessions` rows and refresh tokens stay, so lifting the ban revives
every refresh token that wasn't used during it. `suspendAccount` bans the
user, and lifting the ban with it ends every session first, so the user signs
in again. `endSessions` ends the sessions without a ban, for a forced sign-out
everywhere:

```ts title="src/features/admin/user-actions.ts"
"use server";

import { bs } from "@/lib/supabase/server";

export async function suspendUser(userId: string, suspended: boolean) {
  return bs.suspendAccount(userId, { suspended });
}

export async function signOutEverywhere(userId: string) {
  return bs.endSessions(userId);
}
```

```ts
// { ok: true, data: { userId, suspended: true, ended: 0 } }
```

`suspendAccount` sets the Auth ban (`duration` takes a Go duration such as
`"24h"`, and defaults to 100 years) and keeps the user's sessions. While a
session exists, Auth answers a refresh or `GET /auth/v1/user` with
`user_banned`, so [`sessionStatus`](/docs/auth/sessions) returns `"banned"`
and `bs.proxy({ endedSession })` redirects with `reason=account_banned`. Once
the sessions are deleted, Auth answers `session_not_found` instead and the app
can only report `"ended"`.

Lifting the ban (`suspended: false`) deletes the user's rows from
`auth.sessions` and `auth.refresh_tokens` first, then lifts it. `ended` counts
the sessions deleted: `0` on a suspension, and the sessions kept during the
ban when it is lifted. Both need `SUPABASE_SECRET_KEY` and the server's
`postgres`, since the Data API roles can't write `auth.sessions`; without
`postgres` they return an `invalid_request` error. `bs` from `createNext` also
drops the user's cached sessions.

Access tokens already issued stay valid until they expire. The
`session_active()` policy from [Ended sessions](#ended-sessions) refuses them
at once, because it treats a banned user's session as inactive. Without that
policy, pass `endSessions: true` to delete the sessions on suspension too; an
access token still works against the Data API until it expires, but it can no
longer be refreshed, and the app sees `"ended"` instead of `"banned"`:

```ts
await bs.suspendAccount(userId, { suspended: true, endSessions: true });
// { ok: true, data: { userId, suspended: true, ended: 3 } }
```

Apps without a better-supabase server import both from
`better-supabase/server`:

```ts
import { endSessions, suspendAccount } from "better-supabase/server";

await suspendAccount(supabaseAdmin, postgres.admin, userId, {
  suspended: true,
});
await endSessions(postgres.admin, userId);
```

## Recording account actions in the audit log [#recording-account-actions-in-the-audit-log]

Pass an `EventSink` as the server's `audit` option, such as the audit log's
`sink()`. The server methods `suspendAccount`, `deleteAccount` and
`endSessions` then send a CloudEvent after each success, and the server
forwards `support.denied` events to the same sink:

```ts title="src/lib/server.ts"
import { createAuditLog, rpcTransport } from "better-supabase/blocks/audit";
import { createServer } from "better-supabase/server";

const audit = createAuditLog({ transport: rpcTransport(supabaseAdmin) });

export const bs = createServer(betterSupabase, {
  postgres,
  audit: audit.sink(),
});
```

| Action                    | Event type                                   |
| ------------------------- | -------------------------------------------- |
| `suspendAccount` (ban)    | `dev.better-supabase.account.suspended`      |
| `suspendAccount` (lift)   | `dev.better-supabase.account.unsuspended`    |
| `deleteAccount`           | `dev.better-supabase.account.deleted`        |
| `endSessions`             | `dev.better-supabase.account.sessions_ended` |
| a refused support session | `dev.better-supabase.support.denied`         |

Each event's subject is `users/<id>` and its source is `SERVER_EVENT_SOURCE`
(`/better-supabase/server`). A failed send goes to the logger and never fails
the action. The standalone functions from `better-supabase/server` send
nothing; only the server's methods do.

## What it does [#what-it-does]

1. **Storage, listed.** For each bucket in `buckets` with an owner
   placeholder, it lists the objects that placeholder fills with the user
   id. The owner placeholder is `owner.param` for `owner`
   buckets and `{userId}` for any other template that has one. Buckets
   without one (tenant logos, say) are skipped. Put `{userId}` first in the
   path when you can: a template like `{organizationId}/{userId}/{file}` has to list the
   whole bucket to find the user's objects.
2. **Auth.** `auth.admin.deleteUser(userId)`. Postgres then applies the
   foreign keys to `auth.users`: `on delete cascade` rows go, `set null`
   columns clear.
3. **Storage, removed.** Once the user is gone, the listed objects are
   removed in batches of 1,000.
4. **Events.** A `mutation` notice for `auth.users` (`kind: 'delete'`, the
   user id as the row), then a table-wide delete notice for each table in
   `cascades`, so [cache adapters](/docs/concepts/caching) drop the reads the database
   changed behind their back. Sinks and audit forwarders see the delete too.
5. **Sessions.** `bs.deleteAccount` also calls
   `bs.invalidateSession(userId)`, so `bs.cached()` entries for the user
   are gone.

A delete the database refuses (an organization's only owner under the
organizations module's `ownerInvariant`, say) leaves the objects in place.
Auth reports such a refusal only as a generic failure, so with
`sql: postgres.admin` (which `bs.deleteAccount` passes when the server has
`postgres`) the delete is replayed in a statement that is rolled back, and
the database's own error comes back instead, with its SQLSTATE and hint
(`ORGANIZATION_OWNER_REQUIRED`). If removing objects fails after the user is
deleted, the error names the bucket; remove the rest with the bucket's
`remove`.

## Errors [#errors]

| kind           | When                                                                              |
| -------------- | --------------------------------------------------------------------------------- |
| `conflict`     | A foreign key to `auth.users` blocks the delete (`hint` points at BS406)          |
| database kinds | With `sql`, the database's own refusal, with its `hint` and `table: "auth.users"` |
| `not_found`    | No user with that id                                                              |
| `network`      | Auth or Storage is unreachable                                                    |
| Storage kinds  | A list or remove failed, with `table` set to the bucket id                        |

## Foreign keys [#foreign-keys]

A foreign key to `auth.users` without `on delete` defaults to `no action`,
and Auth answers every delete with "Database error deleting user" while such
rows exist. [`doctor`](/docs/cli/doctor#bs406) reports these keys as BS406.

```sql
-- Data that belongs to the user goes with them.
user_id uuid not null references auth.users (id) on delete cascade,
-- Shared records keep their row and lose the author.
created_by uuid references auth.users (id) on delete set null,
```

## Tokens stay valid until they expire [#tokens-stay-valid-until-they-expire]

Deleting the Auth user revokes their refresh tokens, but access tokens are
verified locally and stay valid until `exp`, one hour by default. Requests in
that window still carry the old `sub`. RLS policies on rows that were
cascaded find nothing, and a policy that joins `auth.users` or a profile
table denies them. If that window matters, shorten `jwt_expiry` or add the
`session_active()` policy from [Ended sessions](#ended-sessions) to the tables
that guard sensitive writes.

In the browser, sign out locally after the action succeeds. A normal
`signOut()` would call Auth for a user that no longer exists:

```ts
await supabase.auth.signOut({ scope: "local" });
```

## Exports and retention [#exports-and-retention]

Deletion is often the last step of a GDPR request that starts with an export.
Run the export first with `bs.admin()` (it bypasses RLS), store it where
the user can download it, then call `deleteAccount`. Rows kept for legal
reasons (invoices, audit logs) should reference the user with
`on delete set null` and keep a copy of what they need, such as the email at
the time of purchase.

# Environment

> Validated Supabase settings, with every framework spelling and no leaked values.

Source: https://bettersupabase.com/docs/auth/env

```ts
import { loadEnv, publicEnv } from "better-supabase/env";

const env = loadEnv(); // reads process.env, throws EnvValidationError
const browser = publicEnv(env); // { url, publishableKey }, safe to ship
```

`loadEnv()` accepts the spellings frameworks use, the first one set wins:

| Setting          | Variables                                                                                                   |
| ---------------- | ----------------------------------------------------------------------------------------------------------- |
| `url`            | `SUPABASE_URL`, with `NEXT_PUBLIC_`, `VITE_`, `PUBLIC_`, `EXPO_PUBLIC_`, `NUXT_PUBLIC_`                     |
| `publishableKey` | `SUPABASE_PUBLISHABLE_KEY` (same prefixes), `SUPABASE_PUBLISHABLE_DEFAULT_KEY`, `SUPABASE_PUBLISHABLE_KEYS` |
| `secretKey`      | `SUPABASE_SECRET_KEY`, `SUPABASE_SECRET_KEYS`                                                               |
| `dbUrl`          | `SUPABASE_DB_URL`, `DATABASE_URL`                                                                           |
| `jwksUrl`        | `SUPABASE_JWKS_URL`, else derived from `url`                                                                |
| `jwks`           | `SUPABASE_JWKS`: inline keys as `{"keys":[...]}` or `[...]`, used instead of fetching `jwksUrl`             |
| `readUrl`        | `SUPABASE_READ_URL`, a [read replica](/docs/guides/read-replicas) API URL (server only)                     |
| `jwtSecret`      | `SUPABASE_JWT_SECRET`, the HS256 secret [Supabase Lite](/docs/platform/lite) signs with (server only)       |

## What is checked [#what-is-checked]

* The URL is `https`, or `http` on `localhost`, `127.0.0.1` or `[::1]` only.
* Keys use the new format: `sb_publishable_…` and `sb_secret_…`. Legacy
  `eyJ…` JWT keys are rejected with a pointer to the API Keys settings.
* A secret key in a publishable variable is an error, not a warning.
* `SUPABASE_JWKS` is JSON with at least one key that has a `kty`, read the
  way `@supabase/server` reads it.
* `SUPABASE_JWT_SECRET` has at least 32 characters. Only `backend: 'lite'`
  reads it.
* `require: ['secretKey', 'dbUrl']` makes optional settings mandatory.
* `SUPABASE_PUBLISHABLE_KEYS` and `SUPABASE_SECRET_KEYS` must be JSON objects
  of key names to keys. All secret keys are kept in `env.secretKeys` (the
  single key is `default`), so a server can accept
  [named keys](/docs/auth/server#machine-callers).

Error messages name the variables and never include their values, so they
are safe to log.

## Standard Schema [#standard-schema]

`envSchema()` is the same validator as a [Standard Schema](https://standardschema.dev),
for t3-env, framework config or any other validator slot:

```ts
import { envSchema } from "better-supabase/env";

const result = await envSchema({ require: ["secretKey"] })[
  "~standard"
].validate(process.env);
```

## With `@supabase/server` [#with-supabaseserver]

`toServerEnv(env)` returns the `SupabaseEnv` that `withSupabase({ env })`
and the `@supabase/server` core functions take.

> **Client bundles**
>
> Next.js only inlines `NEXT_PUBLIC_*` variables that are read literally. In
> browser code, pass them explicitly:
> `loadEnv({ NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL, … })`.

# Impersonation

> Let support staff view the app as a user, read-only by default, with every session recorded and audited.

Source: https://bettersupabase.com/docs/auth/impersonation

Support staff sometimes need to see what a user sees, or fix their data the
way the app would. A support session does this for the whole app: the admin
picks a user, and every page, action and route renders as that user, under
their RLS policies, until the session ends or expires. The admin stays signed
in as themselves the whole time.

## Support sessions [#support-sessions]

Install the `support-sessions` SQL module, then pass a store to `createServer`:

```bash
npx better-supabase sql add support-sessions
```

```ts title="src/lib/better-supabase.ts"
import {
  createServer,
  sqlSupportStore,
  supportSessions,
} from "better-supabase/server";

export const bs = createServer(betterSupabase, {
  env,
  postgres,
  support: supportSessions({
    store: sqlSupportStore(postgres),
    policy: { ttl: 30 * 60, requireReason: true },
  }),
});
```

`supportSessions` lives in its own function so apps without support mode
don't bundle it. `createNext` takes the same option, and `better-supabase/next`
exports `supportSessions` too.

The module adds a `support_sessions` table and four functions:
`start_support_session`, `end_support_session`, `active_support_session` and
`list_support_sessions`. Starting a session checks
`is_platform('support.start')` in the database with the admin's own claims, so
only admins your [access model](/docs/blocks/access) grants that permission
can start one. Grant it with a platform permission claim (`"platform_permissions": ["support.*"]`)
or a platform role in the catalog model.

In Next.js, start and stop the session from server actions:

```ts title="app/admin/users/actions.ts"
"use server";
import { bs } from "@/lib/supabase/server";

export async function viewAs(userId: string, reason: string) {
  return bs.startSupport({ targetUserId: userId, reason });
}

export async function stopViewing() {
  return bs.stopSupport();
}
```

`startSupport` records the session, sets an HTTP-only `bs-support` cookie that
holds the session id, and returns an `ActionResult`. From the next request on,
`bs.session()`, `bs.context()`, actions, routes and `bs.cached()` all run as
the target. Ending the session (or its expiry) brings the admin back to their
own view. Outside Next.js, `bs.support.start(auth, request)` returns the
session and the `Set-Cookie` header to send.

### What the admin can do [#what-the-admin-can-do]

| Policy option   | Default    | Effect                                                                                    |
| --------------- | ---------- | ----------------------------------------------------------------------------------------- |
| `ttl`           | `1800`     | Session length in seconds when the request names none                                     |
| `maxTtl`        | `14400`    | Longest session allowed (`SUPPORT_TTL` above it); the SQL module checks `maxTtl` too      |
| `requireReason` | `true`     | Refuses a start without a reason (`SUPPORT_REASON_REQUIRED`)                              |
| `readOnly`      | `"always"` | Every session is read-only; `"default"` lets the request pass `readOnly: false` for fixes |

A read-only session runs each transaction with `begin read only`, so any write
fails with Postgres error `25006`. In a support session, `ctx.supabase` and
`ctx.db.$client` throw: the Data API would only see the admin's own token, so
everything runs over [Postgres](/docs/auth/postgres) with the target's claims.

Starting a second session ends the admin's first one. Admins can't start a
session for themselves (`SUPPORT_SELF`) or from inside another one
(`SUPPORT_NESTED`).

### Deciding who may start one [#deciding-who-may-start-one]

`authorize` runs before the store, with the admin's verified auth, the target
and the reason. Returning `false` (or throwing) refuses the start with
`SUPPORT_FORBIDDEN`:

```ts
support: supportSessions({
  store: sqlSupportStore(postgres),
  authorize: ({ admin, targetUserId }) =>
    admin.claims.app_metadata?.role === "support" && targetUserId !== OWNER_ID,
}),
```

In SQL, the `before_support_start(admin, target, reason, metadata)` hook can
refuse a start too, for example when no open ticket names the user. See
[support sessions](/docs/blocks/sql#support-sessions) for the module's names,
hooks and options.

### The target's claims [#the-targets-claims]

The target gets the claims your [custom access token hook](/docs/auth#typed-claims)
would give them, so tenant and role claims match a real session. Set
`blocks["support-sessions"].options.claimsHook` to the hook function
(`"public.custom_access_token_hook"`) and `support_target_claims` calls it with
a synthetic event. Without a hook, the target gets the claims Auth puts in
every token (`email`, `role`, `app_metadata`, `user_metadata`, `aal1`). Pass
`claims` in the options to build them in TypeScript instead.

### Showing a banner [#showing-a-banner]

`useSupportSession()` from `better-supabase/react` returns the session while
the admin views the app as someone else, and `undefined` otherwise:

```tsx title="components/support-banner.tsx"
"use client";
import { useSupportSession } from "better-supabase/react";
import { stopViewing } from "@/app/admin/users/actions";

export function SupportBanner() {
  const support = useSupportSession();
  if (!support) return null;
  return (
    <div role="status">
      Viewing as {support.targetUserId}
      {support.readOnly ? " (read-only)" : ""}
      <button onClick={() => stopViewing()}>Stop</button>
    </div>
  );
}
```

On the server, `supportOf(session)` returns the same value.

### Events and the audit log [#events-and-the-audit-log]

`sb.on` receives `support.started`, `support.ended` (with `endedBy`: `admin`,
`expired` or `revoked`) and `support.denied` (with the denial code).
The SQL module writes `support.started` and `support.ended` to the
[audit log](/docs/blocks/sql#audit-log), and every row the admin changes in a
writable session records `impersonated_by` and `support_session_id`, so you
can list everything one session did.

`bs.support.list(auth, { active: true })` lists running sessions for an
admin page, and `bs.support.revoke(auth, id)` ends a session. Both run as the
caller: the SQL store lists sessions for staff with `support.read`, and ends
the caller's own session or, with `support.revoke`, anyone's.

The SQL module refuses a target who holds platform permissions (the platform
claim in `app_metadata`, or a platform role), because the admin would gain
that staff member's reach (`allowPlatformTargets` turns this off). Sessions
are read-only unless `blocks["support-sessions"].options.allowWrites` is true and
the policy is `readOnly: "default"`. A token that carries an `act` claim never
has platform permissions, so a support session can't start another one or act
as staff. Each request checks again that the admin still holds
`support.start`, and an admin has one active session at a time.

### Bringing your own store [#bringing-your-own-store]

`SupportSessionStore` (`apiVersion: 1`) is the interface behind
`sqlSupportStore`. Implement `start`, `get`, `end` and `list` to keep sessions
somewhere else, and run `testSupportSessionStore` from `better-supabase/testing`
against it (see [conformance](/docs/extending/conformance)).

## Acting as a user in code [#acting-as-a-user-in-code]

`actingAs` runs repositories as a user for one piece of server code, without a
session or a cookie. Use it in jobs and scripts:

```ts title="app/admin/actions.ts"
const db = bs.actingAs(
  userId,
  { tenant_id: organizationId },
  { actor: admin.id, reason: "Support ticket 4211" },
);
await db.invoices.update(id, { status: "void" });
```

The third argument adds an [RFC 8693](https://www.rfc-editor.org/rfc/rfc8693#section-4.1)
`act` claim to the session: `{ kind: "impersonation", sub: admin.id, reason }`. Policies still see
`auth.uid() = userId`. Check who acts first: `actingAs` trusts its caller, so
only call it after your own admin check.

Jobs run as the user who enqueued them with `bs.forContext(job.context)`,
which keeps the impersonating admin when the job was enqueued during
impersonation. See
[Jobs, webhooks and agents without a session](/docs/guides/without-a-session).

## What gets recorded [#what-gets-recorded]

| Where              | What                                                                                                                             |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `audit` SQL module | `audit_events.impersonated_by`, `impersonation_reason` and `support_session_id` on every row the admin changes                   |
| `actor` SQL module | `track_actor(table, impersonated_by => 'impersonated_by')` stamps the admin on each write; the user's own writes cannot clear it |
| Repository context | `context.actor.impersonator`, for plugins                                                                                        |
| In SQL             | `auth.jwt() -> 'act' ->> 'sub'` and `auth.jwt() -> 'act' ->> 'session_id'`, for your own triggers and policies                   |

A policy can also refuse impersonated writes where they make no sense:

```sql
create policy "no impersonated payouts" on public.payouts
  as restrictive for insert
  with check (auth.jwt() -> 'act' is null);
```

## Why only over Postgres [#why-only-over-postgres]

Support sessions and `actingAs` need [`postgres`](/docs/auth/postgres) in
`createServer`. They set the claims for the transaction itself, the way
PostgREST does after checking a token. The Data API has no equivalent:

* PostgREST only trusts tokens signed by the project's JWT signing keys. With
  asymmetric keys, only the Auth server holds the private key, so the app
  can't mint a token with an `act` claim.
* Auth has no impersonation API. A token you could mint (with a legacy
  shared secret) would be a full session for the user, with no expiry control
  and no record in Auth's audit log.

So over the Data API, the claims are whatever Auth issued. Run impersonated
work on the server over Postgres, and keep the admin signed in as themselves.

## Reading the act claim [#reading-the-act-claim]

`act` is the [RFC 8693](https://www.rfc-editor.org/rfc/rfc8693#section-4.1)
actor claim, which OAuth clients and agent chains carry too. Its `kind`
says which of them a token is, and `session.actor` and `session.impersonator`
always agree:

| `act`                                                                             | `session.actor.kind`                       | `session.impersonator` | `session.delegation`            |
| --------------------------------------------------------------------------------- | ------------------------------------------ | ---------------------- | ------------------------------- |
| `{ kind: "support", sub, reason, session_id, read_only }`, from a support session | `support`, with `sessionId` and `readOnly` | set                    | never                           |
| `{ kind: "impersonation", sub, reason }`, from `actingAs`                         | `impersonation`                            | set                    | never                           |
| no `kind`, from an OAuth client or an agent chain                                 | `oauth-client`, with the `chain`           | not set                | the `scope` claim and the chain |
| none, with a `client_id` (Supabase's OAuth server)                                | `oauth-client`                             | not set                | the `scope` claim               |

Only an `oauth-client` actor is limited to the scopes the user delegated: the
`scopes` guard option checks a support or impersonated session as the user's
own, and a read-only support session still blocks writes. A support token
from 0.5.0, with `session_id` but no `kind`, counts as a support session
until 0.6. An `act` claim that is not a chain of objects each with a `sub`,
that has another `kind`, or a support level without `session_id` makes the
session `{ kind: 'invalid', reason: 'actor' }`.

`impersonatorOf(claims)` from `better-supabase/server` reads the same claim
anywhere else, and `supabaseClaimFixtures` from `better-supabase/testing`
holds a token of each kind, for tests:

```ts title="tests/support-banner.test.ts"
import { supabaseClaimFixtures } from "better-supabase/testing";

const { claims, expect: read } = supabaseClaimFixtures.supportSessionReadOnly;
// read.actor: { kind: "support", id, sessionId, readOnly: true, reason }
```

# Overview

> One resolver for every caller, local verification, and refreshes only where they belong.

Source: https://bettersupabase.com/docs/auth

Every server entry point in better-supabase starts with the same question:
who is calling? `resolveAuth(request)` answers it in this order:

1. your own [`AuthResolver`s](#custom-credentials) (API keys, third-party auth);
2. `Authorization: Bearer <jwt>`, for APIs, MCP servers and mobile apps;
3. the `@supabase/ssr` session cookie, for browsers.

```ts
import { resolveAuth } from "better-supabase/server";
import { loadEnv } from "better-supabase/env";

const env = loadEnv();
const { auth } = await resolveAuth(request, { env });

switch (auth.kind) {
  case "user": // auth.user.id, auth.claims, auth.token
  case "service": // a secret key, when `secret: true`
  case "anon": // auth.reason: 'none' | 'expired' | 'signed_out' | 'refresh_failed'
  case "invalid": // auth.reason: 'token' (did not verify) | 'claims' (failed betterSupabase.claims) | 'actor' (malformed act chain): answer 401
}
```

## No needless work [#no-needless-work]

* A valid token costs **no network call**. It is verified locally against
  the project's JWKS (`<url>/auth/v1/.well-known/jwks.json`), which is
  fetched once and cached.
* Nothing is written while the token is valid: no cookie churn, no
  `Set-Cookie` on every response.
* A Bearer token that fails verification is `invalid`, never silently
  downgraded to anonymous.

## Anonymous users [#anonymous-users]

A user from `signInAnonymously()` is a `user` with `is_anonymous: true` in
the token, and `toSession(auth).anonymous` is `true`. Every adapter's guard
refuses these users with a 403 (`code: "ANONYMOUS_USER"`) unless `allow` lists
`'anonymous'`, so a guest session never reaches a route meant for accounts.
The `realtime-tables` SQL module sends them no change signals.

| `allow`                  | Full users | Anonymous sign-ins   | No session |
| ------------------------ | ---------- | -------------------- | ---------- |
| `['user']` (the default) | admitted   | 403 `ANONYMOUS_USER` | 401        |
| `['user', 'anon']`       | admitted   | 403 `ANONYMOUS_USER` | admitted   |
| `['user', 'anonymous']`  | admitted   | admitted             | 401        |
| `['anonymous']`          | 403        | admitted             | 401        |

`'anon'` is the caller without a session and never admits an anonymous
sign-in; until 0.5.1 it did. Draw the same line in your RLS policies: a
policy that should not serve a guest session checks
`(select (auth.jwt() ->> 'is_anonymous')::boolean) is not true`.

```ts
app.use("/cart/*", bs.middleware({ allow: ["user", "anonymous"] }));
```

> **Use asymmetric signing keys**
>
> Local verification needs asymmetric JWT signing keys (ES256 or RS256), whose
> public half is in the JWKS. Hosted projects use them by default; for the local
> stack, `better-supabase keys` creates `signing_keys_path` and `better-supabase
>   doctor` warns when it is missing. With a legacy HS256 secret there is no
> public key to check against. This is what lets the Next.js proxy, Server
> Components and `'use cache: private'` session reads stay free of network calls
> ([Cache Components](/docs/frameworks/next-cache-components)).

## Refreshing [#refreshing]

An expiring cookie session is refreshed only when you pass `refresh: true`,
which you should only do where cookies can be written: the proxy or
middleware in front of page loads and server actions. With `refresh: true`
the session is refreshed when its token expires within `leeway` seconds (60
by default). Everywhere else the token stays valid until its `exp`, and only
an expired session reads as `{ kind: 'anon', reason: 'expired' }`.

When it does refresh:

* concurrent requests carrying the same refresh token share **one** request
  to Supabase Auth, and a finished refresh is reused for ten seconds, so
  parallel page loads and prefetches converge on one session instead of
  tripping refresh-token reuse detection;
* the new session is written in the exact `@supabase/ssr` format, stale
  cookie chunks are expired, and the response gets `no-store` headers so no
  CDN caches one user's session for another;
* `requestCookies` holds the cookies the rest of the request should see, so
  Server Components rendered after the proxy use the new token;
* a rejected refresh token signs the user out (cookies cleared), and the
  rejection is reused for ten seconds as well; a network failure keeps the
  session so the next request can retry, and a token refreshed early (inside
  `leeway`) still resolves the user until it expires, otherwise the caller is
  `anon` with `reason: "refresh_failed"`;
* a refresh that takes longer than `refreshTimeoutMs` (5000 by default)
  counts as a network failure, so a slow Auth server never holds up the proxy.

```ts
const resolution = await resolveAuth(request, { env, refresh: true });
const response = await next(request, resolution.requestCookies);
return resolution.apply(response); // Set-Cookie + Cache-Control
```

The `/next` adapter wires this up for you in `proxy()`.

> **Refresh races across instances**
>
> Single-flight works per server instance. Across instances Supabase Auth's
> refresh-token reuse interval (10 seconds by default) makes concurrent
> refreshes safe. Keep it enabled; `better-supabase doctor` checks it.

## Custom credentials [#custom-credentials]

`AuthResolver` is an extension point. Return an `AuthState` when the request
carries something you understand, `undefined` otherwise:

```ts
const apiKeys: AuthResolver = {
  name: "partner-api-keys",
  async resolve(request) {
    const key = request.headers.get("x-api-key");
    if (!key) return undefined;
    const partner = await lookupPartner(key);
    return partner ? { kind: "service", keyName: partner.id } : undefined;
  },
};

await resolveAuth(request, { env, resolvers: [apiKeys] });
```

Resolvers may leave `reason` out of an `invalid` state; it counts as
`'token'`. Users they return go through the claims schema like any other.

## Typed claims [#typed-claims]

Claims your [custom access token
hook](https://supabase.com/docs/guides/auth/auth-hooks/custom-access-token-hook)
adds are typed `unknown` until you describe them. `betterSupabase.claims(schema)` takes
any [Standard Schema](https://standardschema.dev) (zod, valibot, arktype) and
returns a new definition whose servers validate the verified claims and
whose sessions are typed by the schema's output:

```ts title="src/lib/supabase/index.ts"
import * as v from "valibot";

export const Claims = v.looseObject({
  tenant_id: v.optional(v.pipe(v.string(), v.uuid())),
  roles: v.optional(v.array(v.string()), []),
  memberships: v.optional(
    v.array(
      v.looseObject({
        scope: v.string(),
        id: v.string(),
        roles: v.array(v.string()),
      }),
    ),
    [],
  ),
});

export const betterSupabase = defineSupabase(schema)
  .claims(Claims)
  .use(tenant<v.InferOutput<typeof Claims>>());
```

The tenant plugin, `current_tenant_id()` and the storage and realtime policies
read the active tenant from `tenant_id` (or `app_metadata.tenant_id`).
`claims.tenant` in the config renames it for all of them at once. Memberships
have the shape `{ scope, id, roles }`, and roles and memberships are never
read from `user_metadata`, which users can edit. Use `v.looseObject` so claims
your schema doesn't list, such as fields another hook adds, stay on the
session. `claims(schema)` takes any Standard Schema, so an authorization
library can publish the schema for the claims its hook writes, and you pass
that one instead.

* `createServer`, `createNext`, `createHono` and friends validate after the
  signature check, on every request, including tokens served from the
  verification memo. A token whose claims fail resolves to
  `{ kind: 'invalid', reason: 'claims' }` with an `unauthorized` error
  (`code: 'CLAIMS_INVALID'`) that names the failing paths, never the values.
  The cookie path does not try a refresh for it, since a new token from the
  same hook would fail the same way.
* The output is merged over the payload, so `sub`, `exp` and the other JWT
  claims stay, and defaults such as `roles: []` fill in. The merge is one
  level deep: a nested object in the schema, like `app_metadata`, replaces
  the payload's, so describe it with `v.looseObject()` (or your library's
  equivalent) to keep its other keys.
* `ctx.auth.claims`, `await bs.session()` and the `useSession` from
  `createHooks<typeof bs>()` are typed `JWTClaims & Claims`. The
  standalone `useSession<Claims>()` takes the type explicitly.
* `tenant<Claims>({ claim })` only accepts dotted paths to string claims,
  such as `'tenant_id'` or `'app_metadata.tenant_id'`. Without `claim` it
  reads `claims.tenant` from the generated schema.

Keep the schema small: it runs on every request, and anything the hook
doesn't always set should be optional or have a default.
`better-supabase doctor` checks the hook itself: its grants
([BS404](/docs/cli/doctor#bs404)), that it is `stable` with an empty
`search_path`, and with `--as <user id>` how large the claims it returns are
([BS405](/docs/cli/doctor#bs405)).

### When claims change [#when-claims-change]

Claims are copied into the access token when Auth issues it, and verified
locally after that. When you change a user's `app_metadata`, their
memberships, or anything the hook reads, the user's current token still
carries the old values until it is refreshed: up to the token's lifetime (an
hour by default), or until the proxy refreshes the session. The same goes for
`auth.jwt()` in policies. Revoking a role therefore takes effect on the next
refresh, not immediately.

When that window matters, read the source of truth instead of the claim:
policies can check the `memberships` table (`member_organization_ids()` from the
[SQL modules](/docs/blocks/sql) does). After a change the user made themselves,
such as joining an organization, the browser can call
`supabase.auth.refreshSession()` to pick up the new claims at once. Shorter
token lifetimes narrow the window for everyone
([BS402](/docs/cli/doctor#bs402)).

## Profile [#profile]

`user_metadata` holds what users say about themselves: a display name, an
avatar, a theme. `betterSupabase.userMetadata(schema)` parses it into a typed
`session.profile` for rendering:

```ts title="src/lib/supabase/index.ts"
export const Profile = v.object({
  display_name: v.optional(v.string()),
  avatar_url: v.optional(v.pipe(v.string(), v.url())),
});

export const betterSupabase = defineSupabase(schema)
  .claims(Claims)
  .userMetadata(Profile);
```

```tsx title="src/app/account/page.tsx"
const session = await bs.session();
if (session.kind === "user") {
  return <h1>Hello, {session.profile?.display_name ?? session.user.email}</h1>;
}
```

Any signed-in user can change their own metadata with `auth.updateUser()`, so
the profile is for display only. Roles, memberships, the tenant and every
policy come from the verified claims, never from `profile` or
`user_metadata`.

* The schema runs after the claims schema, on the `user_metadata` claim of the
  verified token. Metadata that fails it leaves `profile` undefined and logs
  one warning per schema through the `logger` of `defineSupabase` with the
  failing paths, never the values. The request still resolves as the user.
* `session.profile` is typed `Profile | undefined` in `ctx.auth`,
  `bs.session()` and the `useSession` from `createHooks<typeof bs>()`.
* Supabase Auth copies `user_metadata` into the access token. A custom access
  token hook must keep that claim for `profile` to be
  set. The claims budget ([BS405](/docs/cli/doctor#bs405)) counts it, so
  keep large or frequently changing fields in a `profiles` table instead.

## Identity for repositories [#identity-for-repositories]

`authContext(auth)` turns an auth state into the repository context: a typed
`actor` (`user`, `service` or `anon`) and the JWT `claims`. The
[`actor`](/docs/plugins/actor) and [`tenant`](/docs/plugins/tenant) plugins
read it.

# MFA and SSO

> Require a second factor on routes, actions, pages and RLS policies, and scope SSO users to their tenant.

Source: https://bettersupabase.com/docs/auth/mfa-sso

Supabase Auth puts the session's assurance level in the access token: `aal`
is `aal1` after a password, magic link or OAuth sign-in, and `aal2` once the
user verified a TOTP, phone or WebAuthn factor in this session. `amr` lists
how the user signed in. better-supabase reads both locally, like every other
claim, so enforcing MFA costs no Auth server call.

## The session [#the-session]

```ts
const session = await bs.session();
if (session.kind === "user") {
  session.aal; // 'aal1' | 'aal2'
  session.amr; // [{ method: 'totp', timestamp: 1767225600 }, { method: 'password', ... }]
}
```

`aal` reads as `aal1` for any value other than `aal2`. `amr` keeps well-formed
entries only; SSO entries carry the `provider` id.

## Routes and actions [#routes-and-actions]

Every adapter's guard options take `aal`. A user session below it gets `403`
with `code: 'INSUFFICIENT_AAL'` and `required: 'aal2'`, so the client knows to
start a challenge rather than show a generic error:

```ts title="src/app/api/billing/route.ts"
export const POST = bs.route(
  async (request, { db }) => db.invoices.create(await request.json()),
  { aal: "aal2" },
);
```

```ts title="src/features/security/security-actions.ts"
export const rotateApiKey = bs.action({ aal: "aal2" }, async (_input, { db }) =>
  db.$rpc("rotate_api_key"),
);
```

```json title="403 response"
{
  "type": "https://bettersupabase.com/problems/forbidden",
  "title": "Forbidden",
  "status": 403,
  "kind": "forbidden",
  "code": "INSUFFICIENT_AAL",
  "required": "aal2",
  "detail": "Verify a second factor to continue"
}
```

Hono, oRPC, the edge handler and MCP accept the same `aal` option. Outside an
adapter, `checkAal(auth, 'aal2')` from `better-supabase/server` returns the
same error or `undefined`.

## Pages [#pages]

`requireAal` is a `protect` for the proxy. Signed-in users below the level are
redirected to your MFA page with `?next=<path>`:

```ts title="src/proxy.ts"
import { requireAal } from "better-supabase/next";

const mfa = requireAal("aal2", {
  redirect: "/mfa",
  match: (path) => path.startsWith("/settings/security"),
});

export function proxy(request: NextRequest) {
  return bs.proxy(request, { protect: mfa });
}
```

The token carries no list of factors, so a user without one lands on the MFA
page too: enroll there with `supabase.auth.mfa.enroll()`, or challenge with
`supabase.auth.mfa.challengeAndVerify()`. After verifying, the client holds
an `aal2` session and the redirect back to `next` passes. Without
`redirect`, `requireAal` answers with the 403 Problem Details above.

## Row-level security [#row-level-security]

Guards protect your server code; RLS protects the data from every client,
including direct PostgREST calls with the user's token. The `mfa` SQL module
adds `better_supabase.mfa_satisfied()`:

```bash
better-supabase sql add mfa
```

```sql
create policy mfa_required on public.invoices as restrictive
  for all to authenticated
  using ((select better_supabase.mfa_satisfied()))
  with check ((select better_supabase.mfa_satisfied()));
```

* It is true when the caller has no verified factor, or the token is `aal2`.
  Users who never enrolled keep working; users who did must verify it in
  each session. To require MFA for everyone, check `auth.jwt() ->> 'aal'`
  directly instead.
* `restrictive` means it applies on top of the table's other policies
  instead of widening them.
* It is `security definer` because `authenticated` can't read
  `auth.mfa_factors`. A policy that reads that table directly fails every
  request with `42501`; [`doctor`](/docs/cli/doctor#bs108) reports it as
  BS108.
* Execute is granted to `authenticated` only.

## SSO tenants [#sso-tenants]

With SAML SSO, the first `amr` entry of an SSO session is
`{ method: 'sso/saml', provider: '<sso provider id>' }`. When each tenant signs
in through its own identity provider, scope reads by the provider:

```ts title="src/lib/supabase/index.ts"
export const betterSupabase = defineSupabase(schema).use(
  tenant({ claim: ["tenant_id", "amr.0.provider"] }),
);
```

When tenant ids are not provider ids, map them with `resolve`, and back it
with a restrictive policy so a user from another identity provider can't
read the tenant's rows through PostgREST:

```ts
tenant({
  resolve: (context) =>
    providers.get(amrOf(context.claims ?? {})[0]?.provider ?? ""),
});
```

```sql
alter table public.organizations add column sso_provider_id uuid unique;

create policy sso_tenant on public.projects as restrictive
  for all to authenticated
  using (
    organization_id in (
      select id from public.organizations
      where sso_provider_id is null
         or sso_provider_id::text = (select auth.jwt() -> 'amr' -> 0 ->> 'provider')
    )
  );
```

Organizations without `sso_provider_id` keep password and OAuth sign-ins;
the others only accept sessions from their provider.

# Middleware and proxy

> withBetterSupabase as an @supabase/middleware entry, framework bridges, and composing the Next.js proxy with i18n, rewrites and Server-Timing.

Source: https://bettersupabase.com/docs/auth/middleware

## Middleware entries [#middleware-entries]

`withBetterSupabase(bs)` from `better-supabase/server` is one
`@supabase/middleware` entry that resolves the caller (a bearer token first,
the session cookie second), enforces a guard and contributes the caller's
context. It composes by position with other entries, and the type checker
rejects a wrong order or two entries that write the same key.

```ts title="src/index.ts"
import { pipeline } from "@supabase/middleware";
import { withPostgresClient } from "@supabase/server/middleware/postgres";
import { createServer, withBetterSupabase } from "better-supabase/server";
import { betterSupabase } from "./lib/supabase";

const bs = createServer(betterSupabase);

export default {
  fetch: pipeline(
    [
      withBetterSupabase(bs, { allow: ["user"] }),
      withPostgresClient(), // ctx.postgres, raw queries as the caller
    ],
    async (req, ctx) => {
      const customers = await ctx.db.customers
        .findMany({ select: ["id", "name"] })
        .orThrow();
      return Response.json(customers);
    },
  ),
};
```

`withBetterSupabase(betterSupabase, options)` also takes the definition and
builds the server from the same options `createServer` takes.

| Key          | What it holds                                                                                              |
| ------------ | ---------------------------------------------------------------------------------------------------------- |
| `bs`         | the request's `ServerContext`: `auth`, `db`, `actingAs`, `apply` and the rest of `server.context(request)` |
| `db`         | the caller's repositories                                                                                  |
| `sql`        | repositories over direct Postgres when `createServer` has `postgres`, else `undefined`                     |
| `jwtClaims`  | the verified claims, as `withSupabase` names them                                                          |
| `userClaims` | `{ id, email, role, ... }` for a user, `null` otherwise                                                    |
| `authMode`   | `user`, `secret` or `none`, as `withSupabase` names them                                                   |
| `tenant`     | the result of `ServerOptions.tenant`                                                                       |
| `support`    | the active support session, if any                                                                         |
| `replica`    | the read replica router, when `readUrl` is set                                                             |

The keys `withSupabase` contributes (`jwtClaims`, `userClaims`, `authMode`)
are the same, so entries written for `withSupabase`, such as
`withPostgresClient`, run after `withBetterSupabase` unchanged. Don't put both
in one pipeline: the type checker reports the collision.

`withPostgresClient` reads `jwtClaims`, so it runs after `withBetterSupabase`
and adds `ctx.postgres` for raw queries; it doesn't back `ctx.sql`. For
repositories over direct Postgres, pass `postgres: createPostgres()` to
`createServer`.

On the way out, the entry adds refreshed session cookies, the `no-store`
headers and `bs-primary-until` after a write, and hands pending event sends
to `waitUntil`.

| Option                   | What it does                                                                                                               |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| `allow`, `aal`, `scopes` | the guard: a refused caller gets 401 or 403 Problem Details and the handler never runs                                     |
| `refresh`                | refresh an expired cookie session; set it only where the response can carry cookies once per request                       |
| `cookies`                | `false` reads bearer tokens only                                                                                           |
| `encode`                 | `user-and-tokens` or `tokens-only`, the shape refreshed sessions are written in; use the browser client's `cookies.encode` |
| `cookieScopes`           | `{ domain, path }` scopes earlier deploys wrote the session at; each response expires the session cookie there             |
| `waitUntil`              | keeps the invocation alive for event sends, for example `ctx.waitUntil` on Workers                                         |
| `expose`                 | include error details in the guard's Problem Details                                                                       |

### Framework bridges [#framework-bridges]

A bridge runs an entry array in a framework's middleware slot, so the
framework's response passes back through the entries and refreshed cookies
reach it.

| Framework               | Bridge                                        | Docs                                              |
| ----------------------- | --------------------------------------------- | ------------------------------------------------- |
| Hono                    | `toHono(entries)` from `better-supabase/hono` | [Hono](/docs/frameworks/hono)                     |
| Edge Functions, Workers | `toEdge(entries, handler)` from `/edge`       | [Edge](/docs/frameworks/edge)                     |
| oRPC                    | `toOrpc(entries, handler)` from `/orpc`       | [oRPC](/docs/frameworks/orpc)                     |
| Expo Router API routes  | `toExpo(entries, handler)` from `/expo`       | [Expo](/docs/frameworks/expo)                     |
| TanStack Start          | `toTanStackStart(entries)`                    | [TanStack Start](/docs/frameworks/tanstack-start) |
| SvelteKit               | `toSvelteKit(entries)`                        | [SvelteKit](/docs/frameworks/sveltekit)           |
| React Router            | `toReactRouter(entries, key)`                 | [React Router](/docs/frameworks/react-router)     |
| H3 2, Nitro 3           | `toH3(entries)`                               | [H3](/docs/frameworks/h3)                         |
| Nitro 2, Nuxt           | `toH3V1(entries)` from `/h3/v1`               | [Nuxt](/docs/frameworks/nuxt)                     |
| Node, Express, Fastify  | `toNodeHandler`, `toExpress`, `toFastify`     | [Node](/docs/frameworks/node)                     |
| Elysia                  | `toElysia(entries)`                           | [Elysia](/docs/frameworks/elysia)                 |

### Blocks on the context [#blocks-on-the-context]

`withBlock(key, create)` is a single-key entry that puts a block service on
the context, built from the keys the entries before it contribute. Blocks take
`ctx.postgres` and `ctx.postgresAdmin` wherever they take a SQL client, so
`withPostgresAdminClient` after `withBetterSupabase` gives them the app's one
pool and connection string (`SUPABASE_DB_URL`):

```ts
import { pipeline } from "@supabase/middleware";
import type { PostgresApi } from "@supabase/server/middleware/postgres";
import { withPostgresAdminClient } from "@supabase/server/middleware/postgres-admin";
import { createJobs } from "better-supabase/blocks/jobs";
import { withBetterSupabase, withBlock } from "better-supabase/server";

const entries = [
  withBetterSupabase(bs, { allow: ["user"] }),
  withPostgresAdminClient(),
  withBlock("jobs", (ctx: { postgresAdmin: PostgresApi }) =>
    createJobs(ctx.postgresAdmin, queues),
  ),
] as const;

export default {
  fetch: pipeline(entries, async (req, ctx) => {
    const id = await ctx.jobs.enqueue("emails", await req.json()).orThrow();
    return Response.json({ id }, { status: 202 });
  }),
};
```

`create` names the keys it reads in its parameter type, and the type checker
reports an entry array that doesn't contribute them before it. The same array
runs under any framework bridge, and after `withSupabase` in place of
`withBetterSupabase`. A cron drain route checks its own bearer secret, so it
needs only `withPostgresAdminClient()` and `withBlock`. See
[Connections](/docs/blocks#connections) for which client each block needs.

### Next to withSupabase [#next-to-withsupabase]

An app that already runs `withSupabase` adds repositories with the leaf
entries instead. `withBetterDb(betterSupabase)` adds `ctx.db` from
`ctx.supabase`, and `withBetterPostgres(betterSupabase)` adds `ctx.sql` from
`ctx.postgres`:

```ts
import { withSupabase } from "@supabase/server";
import { withPostgresClient } from "@supabase/server/middleware/postgres";
import { withBetterDb, withBetterPostgres } from "better-supabase/server";

const entries = [
  withSupabase({ auth: "user" }),
  withBetterDb(betterSupabase)(),
  withPostgresClient(),
  withBetterPostgres(betterSupabase)(),
] as const;
```

The repository context comes from the `withSupabase` context: `authMode:
'user'` becomes a `user` actor with the JWT claims, `secret` becomes
`service`, anything else `anon`. `contextFromSupabase(ctx)` exposes that
mapping for your own middleware. `ctx.sql` runs on `withPostgresClient`'s
connection, which sets the caller's claims and role per query, so RLS applies
exactly as over PostgREST.

`withSupabaseClient` reads only the `Authorization` header, so it doesn't see
a cookie session. Use `withBetterSupabase` where browsers call with cookies.

### Feature flags [#feature-flags]

`withOpenFeature` from `@supabase-labs/middleware-openfeature` goes after
`withBetterSupabase`, because its `context` callback reads the `jwtClaims`
that `withBetterSupabase` contributes. `createFlagClient` from the
[flags block](/docs/blocks/flags) is a client it accepts, and `flagContext`
turns the claims into the evaluation context:

```ts title="src/server.ts"
import { ErrorCode } from "@openfeature/server-sdk";
import { pipeline } from "@supabase/middleware";
import { withOpenFeature } from "@supabase-labs/middleware-openfeature";
import {
  createFlagClient,
  type FlagContextSource,
  flagContext,
  sqlTransport,
} from "better-supabase/blocks/flags";
import { withBetterSupabase } from "better-supabase/server";

const client = createFlagClient({
  transport: sqlTransport(postgres.admin),
  errorCodes: ErrorCode,
});

export const fetch = pipeline(
  [
    withBetterSupabase(server),
    withOpenFeature({
      client,
      flags: { new_editor: false },
      context: (_req: Request, ctx: FlagContextSource) => flagContext(ctx),
    }),
  ],
  async (_req, ctx) => Response.json({ newEditor: ctx.flags.new_editor }),
);
```

In the pipeline form the `context` callback needs the `ctx` annotation,
since TypeScript checks the config before `pipeline` sees the array.

## Next.js proxy composition [#nextjs-proxy-composition]

`bs.proxy(request, options)` owns the session refresh. Other middleware
(i18n, rewrites, A/B buckets) goes in `before` and `after` instead of
wrapping the proxy, so cookies and forwarded request headers are merged once
and in the right order:

```ts title="src/proxy.ts"
import createMiddleware from "next-intl/middleware";
import type { NextRequest } from "next/server";
import { bs } from "@/lib/supabase/server";
import { routing } from "@/i18n/routing";

const intl = createMiddleware(routing);

export const proxy = (request: NextRequest) =>
  bs.proxy(request, {
    before: intl,
    protect: (auth, req) => (auth.kind === "user" ? undefined : toLogin(req)),
    after: (response, auth) => {
      if (auth.kind === "user") response.headers.set("x-user-id", auth.user.id);
    },
    serverTiming: true,
  });
```

The order is:

1. `before(request)` and the session check run in parallel. The session is
   always resolved on the original request, and `shouldRefresh` decides
   exactly as without `before`: prefetches never refresh.
2. `protect(auth, request)` runs. A response it returns wins over `before`'s.
3. Refreshed or cleared cookies are added to the chosen response (`before`'s
   rewrite, `protect`'s redirect or a plain `NextResponse.next()`).
4. For rewrites and pass-throughs, the new `cookie` header is forwarded to
   Server Components through `x-middleware-override-headers`. Request headers
   `before` already overrides (next-intl's locale header, for example) are
   kept, and the list is rebuilt, not replaced. Redirects get only
   `Set-Cookie`.
5. `after(response, auth)` edits the response in place or returns a new one.

With a valid token and nothing to merge, the proxy still returns
`NextResponse.next()` without touching headers.

### next-intl with `localePrefix: 'never'` [#next-intl-with-localeprefix-never]

When the locale lives in a cookie instead of the path, every URL is the same
for every locale and next-intl rewrites internally to `/[locale]/...`. Use
`before: intl` as above; the rewrite keeps the refreshed session cookie, and
the `[locale]` segment reads the locale from next-intl's request header. Don't
put the locale in `searchParams`: pages that read them lose instant
navigation.

### What a prefetch costs [#what-a-prefetch-costs]

The matcher decides which requests pay for the proxy at all. On a request it
does match, a prefetch costs one memoized local JWT verification (a JWKS
lookup cached for the process, no network) and whatever `before` does. It
never refreshes and never writes cookies. A page load with an expired token
costs one Auth request, done once per request and shared with the Server
Components that render it.

Keep static assets out of the matcher:

```ts
export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|webp)$).*)",
  ],
};
```

Leave `/api` routes in if their handlers read the session. Route handlers
don't refresh, so a user whose token expired on a pure API call gets 401
until the next page load or action.

### Server-Timing [#server-timing]

`serverTiming: true` appends a
[Server-Timing](https://www.w3.org/TR/2026/WD-server-timing-20260407/) header
(pinned in `SPEC_PINS.serverTiming`):

```http
Server-Timing: bs-proxy;dur=1.4, bs-verify;dur=0.6
```

`bs-verify` is the session check (including a refresh when one ran),
`bs-proxy` the whole proxy including `before`, `protect` and `after`. Browsers
show both in the network panel. The header reveals timings only, never
claims; leave it off in production if even that is too much.

### Client IP on refresh [#client-ip-on-refresh]

Auth rate limits token refreshes per IP. Called from your server, every
refresh looks like it came from one IP. When the server has a secret key
(`SUPABASE_SECRET_KEY`), refresh requests send the client IP as
`Sb-Forwarded-For` and authenticate with the secret key, so Auth rate-limits
per user instead.

The IP comes from `clientIp(request)`, the first `x-forwarded-for` hop, which
is right on Vercel and behind most proxies. Pass your own reader or turn it
off:

```ts
createNext(betterSupabase, {
  auth: {
    clientIp: (request) => request.headers.get("cf-connecting-ip") ?? undefined,
    // clientIp: false,
  },
});
```

Auth only honours the header when **IP Address Forwarding** is enabled in
the dashboard (Authentication, Attack Protection). Without it the header is
ignored and refreshes are rate-limited by your server's IP, as before.

# OAuth consent and connected agents

> Serve the consent page for the Supabase Auth OAuth server from your app, and let users see and disconnect the agents they approved.

Source: https://bettersupabase.com/docs/auth/oauth-consent

When the Supabase Auth OAuth server is on, an MCP client or another OAuth
client signs a user in by sending them to a consent page in your app. The
user approves or denies, and Auth redirects back to the client with a code or
an `access_denied` error. The Next.js example serves that page at
`/oauth/consent` and lists the approved clients under Settings, Security.
Both run in the browser with the Supabase client the app already has.

## Configuration [#configuration]

Auth sends users to `authorization_url_path`, relative to the Auth Site URL:

```toml title="supabase/config.toml"
[auth.oauth_server]
enabled = true
authorization_url_path = "/oauth/consent"
```

The page needs a signed-in user. Leave it out of the proxy's public paths:
a signed-out visitor goes to sign-in with the consent URL (including
`authorization_id`) in `next`, and the login form returns them to it through
`safeNext`. The [MCP blocks guide](/docs/guides/supabase-blocks#requirements)
covers the rest of the OAuth server setup.

## The consent page [#the-consent-page]

The page reads `authorization_id` from the URL and asks Auth for the request.
Auth answers with the client, the user and the requested scopes, or with a
`redirect_url` when the user approved this client before:

```tsx title="src/features/auth/components/oauth-consent-form.tsx"
"use client";

// A request the user consented to before is used up by the first read, so a
// second effect run (Strict Mode) has to share it.
const reads = new Map<string, Promise<AuthOAuthAuthorizationDetailsResponse>>();

useEffect(() => {
  const id = new URLSearchParams(window.location.search).get(
    "authorization_id",
  );
  if (!id) return;
  let read = reads.get(id);
  if (!read) {
    read = supabase.auth.oauth.getAuthorizationDetails(id);
    reads.set(id, read);
  }
  void read.then((result) => {
    if (result.error) return showError(result.error.message);
    if (!("authorization_id" in result.data)) {
      window.location.replace(result.data.redirect_url);
      return;
    }
    showConsent(result.data); // client.name, user.email, scope, redirect_uri
  });
}, [supabase]);
```

Share the read per `authorization_id`. For a client the user already
approved, the first read uses up the request, and a second read (React
Strict Mode runs effects twice in development) fails with "authorization
request cannot be processed".

Approve and Deny pass `skipBrowserRedirect` so the page decides when to
leave, and keep the buttons disabled until it does:

```ts
const result = approve
  ? await supabase.auth.oauth.approveAuthorization(id, {
      skipBrowserRedirect: true,
    })
  : await supabase.auth.oauth.denyAuthorization(id, {
      skipBrowserRedirect: true,
    });
if (result.error) return showError(result.error.message);
window.location.assign(result.data.redirect_url);
```

The browser only ever goes to a `redirect_url` that Auth returned, which
points at a redirect URI the client registered. Show the host of
`redirect_uri` on the page so the user sees where approving sends them.

The approved client acts as the user: its tokens carry the user's `sub`, so
RLS and every repository call see the same rows the user does. The page says
so next to the scopes.

## Connected agents [#connected-agents]

`listGrants` returns every client the user approved, with its scopes and
`granted_at`. `revokeGrant` disconnects one:

```tsx title="src/features/user/components/connected-agents-card.tsx"
const { data: grants } = await supabase.auth.oauth.listGrants();

const { error } = await supabase.auth.oauth.revokeGrant({
  clientId: grant.client.id,
});
```

Revoking ends the client's sessions and invalidates its refresh tokens. An
access token it already holds is verified locally and stays valid until it
expires, one hour by default, so say that in the UI. If that window matters,
add the `session_active()` policy from
[Ended sessions](/docs/auth/account-deletion#ended-sessions): the revoked
client's session is gone, so the policy refuses its writes right away.

A denied request creates no grant, so the client never appears in the list.

# Direct Postgres

> The same repositories over SQL, with the same results and RLS.

Source: https://bettersupabase.com/docs/auth/postgres

The repository IR compiles to SQL as well as to PostgREST. Any client with
`queryRaw(text, params)` can run it: `ctx.postgres` from `@supabase/server`,
or a pool from `createPostgres()`.

```ts
import { createPostgres, postgresExecutor } from "better-supabase/postgres";

const postgres = createPostgres(); // SUPABASE_DB_URL or DATABASE_URL

const asUser = betterSupabase.connect(
  postgresExecutor(postgres.asUser({ sub: userId, tenant_id: organizationId })),
);
const asAdmin = betterSupabase.connect(postgresExecutor(postgres.admin));
```

Results match PostgREST exactly: same row shapes, nested includes, relation
filters, pagination and error kinds. The test suite runs the same queries
through both and compares them.

## Identities [#identities]

| Client               | Runs as                                                        |
| -------------------- | -------------------------------------------------------------- |
| `postgres.admin`     | the connection-string role (bypasses RLS on Supabase)          |
| `postgres.asUser(c)` | `authenticated`, with `request.jwt.claims` set, so RLS applies |
| `postgres.anon`      | `anon`                                                         |

`postgres.executorFor(claims)` is `postgresExecutor(postgres.asUser(claims))`.
`createServer` calls it for `ctx.sql` and `actingAs()`, so the SQL compiler is
only bundled into apps that import `better-supabase/postgres`. A custom
`BetterPostgres` passed to `createServer` implements it too.

Claims and role are transaction-local (`set_config(…, true)`), so nothing
leaks back into the pool. Each transaction sets them, with its timeouts, in
one query after `begin`. Only `authenticated` and `anon` can be assumed this
way.

`asUser`, `executorFor` and `transaction` also take session options.
`settings` sets more transaction-local settings, such as
`better_supabase.tenant` for a [resolved tenant](/docs/blocks/access#the-active-tenant);
their names need a dot, so they can't replace `role` or the claims.
`readOnly: true` opens every transaction with `begin read only`, so writes fail
with SQLSTATE 25006.

```ts
const reader = postgres.asUser(claims, {
  settings: { "better_supabase.tenant": organizationId },
  readOnly: true,
});
```

## Timeouts [#timeouts]

`set role` doesn't apply a role's own settings, so the `statement_timeout`
Supabase sets on `authenticated` (8 s) and `anon` (3 s) would not limit these
queries. `createPostgres` applies those two values per transaction instead,
and leaves `admin` without a limit for jobs and migrations.

| Option                     | Default                               | Sets                                                                   |
| -------------------------- | ------------------------------------- | ---------------------------------------------------------------------- |
| `statementTimeout`         | `{ authenticated: 8000, anon: 3000 }` | `statement_timeout` per transaction; a number applies to every role    |
| `idleInTransactionTimeout` | unset                                 | `idle_in_transaction_session_timeout`, so a transaction left open ends |
| `connectionTimeout`        | `10000`                               | how long `connect` waits for a free connection                         |
| `idleTimeout`              | `10000`                               | how long an unused connection stays in the pool                        |

```ts
const postgres = createPostgres({
  statementTimeout: { admin: 60_000, authenticated: 5000 },
  idleInTransactionTimeout: 30_000,
});
```

All values are milliseconds. `connectionTimeout` and `idleTimeout` apply to
the pool `createPostgres` opens, not to one you pass as `pool`.

## Which connection string [#which-connection-string]

Supabase offers three ways in, and the right one depends on where the code
runs:

| Connection                  | Host and port                       | Use it for                       |
| --------------------------- | ----------------------------------- | -------------------------------- |
| Direct                      | `db.<ref>.supabase.co:5432` (IPv6)  | long-running servers, migrations |
| Supavisor, session mode     | `<region>.pooler.supabase.com:5432` | long-running servers on IPv4     |
| Supavisor, transaction mode | `<region>.pooler.supabase.com:6543` | serverless and edge functions    |

Serverless functions open a connection per invocation, so use transaction
mode there and keep `max` small (1 to 3 per instance). Transaction mode
doesn't keep session state between transactions, which `createPostgres`
doesn't need: everything it sets is transaction-local. Doctor's
[BS221](/docs/cli/doctor#bs221) flags a direct or session-mode URL in a
serverless app.

## Transactions [#transactions]

```ts
await postgres.transaction(
  async (tx) => {
    const db = betterSupabase.connect(postgresExecutor(tx), { claims });
    const invoice = await db.invoices.create(data).orThrow();
    await db.invoiceLines.createMany(lines(invoice.id)).orThrow();
  },
  { claims },
);
```

A thrown error rolls everything back. Plugins run as usual inside the
transaction.

## Functions [#functions]

`db.$rpc` works over the Postgres executor too, so a request that runs as an
API key or a job over a direct connection calls the same functions as one
over PostgREST:

```ts
const db = betterSupabase.connect(postgres.executorFor(claims), { claims });
const rows = await db
  .$rpc("customers_by_status", { p_status: "active" })
  .orThrow();
```

It runs `select ... from schema.name(arg => $1, ...)` with the named
arguments and returns what PostgREST returns: an array for a set-returning
or `returns table` function, one value or row otherwise, and `null` for
`void`. It reads whether the function returns a set from the generated
metadata, and sends `json` and `jsonb` arguments as JSON; a function the
metadata doesn't list is looked up in `pg_proc` first. Errors map like any
other statement, and a function that doesn't exist fails with
`invalid_request`, as PostgREST's `PGRST202` does.

## An existing pool [#an-existing-pool]

Pass `pool` to run on a pool you already have, such as the app's `pg.Pool`
or a test double. Anything with `connect()` and `end()` works (the `PgPool`
type), and `postgres.end()` ends it.

```ts
import { Pool } from "pg";
import { createPostgres } from "better-supabase/postgres";

const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
await using postgres = createPostgres({ pool });
```

## When to use it [#when-to-use-it]

* jobs, scripts and webhook handlers (with `actingAs` for user-scoped work);
* several writes that must commit together;
* when PostgREST is not reachable from the runtime.

For browser-facing request handling, PostgREST remains the default: it pools
connections for you and needs no database credentials.

# Problem Details

> DbError as RFC 9457 application/problem+json, with RFC 6750 challenges.

Source: https://bettersupabase.com/docs/auth/problems

Server adapters answer errors with [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)
Problem Details. The `DbError` fields become extension members, so clients
keep the structured error:

```http
HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://bettersupabase.com/problems/conflict",
  "title": "Conflict",
  "status": 409,
  "kind": "conflict",
  "detail": "duplicate key value violates unique constraint …",
  "instance": "/customers",
  "code": "23505",
  "constraint": "customers_organization_id_kvk_key",
  "columns": ["organization_id", "kvk"]
}
```

```ts
import { fromProblem, problemResponse } from "better-supabase";

if (!result.ok)
  return problemResponse(result.error, { instance: url.pathname });

// client side
const error = fromProblem(await response.json()); // back to a DbError
```

Kind-specific details become extension members next to `code`: `constraint`
and `columns` for conflicts, `column` for `not_null`, and `required` (`aal1` or
`aal2`) when a guard's [`aal`](/docs/auth/mfa-sso) rejects the session,
`scopes` when a guard's `scopes` rejects a delegated token
(`code: "INSUFFICIENT_SCOPE"`), `code: "ANONYMOUS_USER"` when a guard turns
away an anonymous user because `allow` lacks `'anonymous'`, `retryAfter` for `rate_limited`, which
also sets the `Retry-After` header, `meter`, `limit` and `retryAfter` for
`quota_exceeded`, and `maxAffected` for `max_affected`.

When an [authorizer](/docs/extending/authorizers) refuses a declared
permission, the `forbidden` error carries `permission` (the permission key)
and one of two codes. `PERMISSION_DENIED` means the authorizer denied it,
failed, or isn't set; `detail` holds its reason. `APPROVAL_REQUIRED` means
the action needs an approval first, and `approval` (`{ "id": "..." }`) names
the pending approval when the authorizer gives one:

```json
{
  "type": "https://bettersupabase.com/problems/forbidden",
  "title": "Forbidden",
  "status": 403,
  "kind": "forbidden",
  "detail": "invoices.update needs an approval",
  "code": "APPROVAL_REQUIRED",
  "permission": "invoices.update",
  "approval": { "id": "apr_9" }
}
```

## Safe by default [#safe-by-default]

Messages of `unexpected`, `network` and `invalid_request` errors can contain
SQL or hostnames, so they are left out unless you pass `expose: true` (for
example in development). Validation errors keep their `issues`.

## Your own error format [#your-own-error-format]

`format` rewrites the body, for apps whose errors use their own `type` URIs
or codes. It gets the Problem Details and the `DbError`; the status and the
`Retry-After` and `WWW-Authenticate` headers stay as they are.

```ts
const toAppError = (problem: ProblemDetails, error: DbError) => ({
  type: `https://errors.example.com/${error.code ?? problem.kind}`,
  title: problem.title,
  status: problem.status,
  detail: problem.detail,
});

problemResponse(error, { instance, format: toAppError });
```

The blocks that answer HTTP requests take the same function as `problem`:
`createIdempotency`, `createWebhookInbox`, `createIncomingWebhooks`, `rateLimited`,
and the `drainRoute`, `deliverRoute` and `relayRoute` options.

## 401 responses [#401-responses]

A 401 carries an [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750)
challenge: `WWW-Authenticate: Bearer realm="supabase", error="invalid_token"`,
without `error` when no credentials were sent. Set the realm with `realm`.

A 403 for a missing OAuth scope carries the RFC 6750 `insufficient_scope`
challenge, naming the scopes the route needs:
`WWW-Authenticate: Bearer realm="supabase", error="insufficient_scope", scope="customers:read"`.

# createServer

> Repositories bound to the caller, plus explicit admin and acting-as identities.

Source: https://bettersupabase.com/docs/auth/server

`betterSupabase` from `defineSupabase` is isomorphic and holds no secrets.
`createServer(betterSupabase)` is its server half: it loads the env, resolves callers and
hands out repositories bound to an identity.

```ts title="src/lib/supabase/server.ts"
import { createServer } from "better-supabase/server";
import { createPostgres } from "better-supabase/postgres";
import { betterSupabase } from "./index";

export const bs = createServer(betterSupabase, {
  postgres: createPostgres(), // optional: enables ctx.sql and actingAs()
});
```

## Per request [#per-request]

```ts
const ctx = await bs.context(request);

ctx.auth; // the resolved AuthState
ctx.db; // repositories over PostgREST as the caller (RLS applies)
ctx.sql; // the same repositories over direct Postgres as the caller
ctx.supabase; // a stateless supabase-js client for storage, functions, …
ctx.apply(response); // refreshed session cookies, and bs-primary-until after a write
ctx.cookies(); // the same cookies as CookieWrite objects, for a framework's cookie API
```

Each client is built on first access. For a signed-in user, `ctx.db` runs on
a bare PostgREST client that carries the user's token, so a request that only
queries data never builds the Realtime, Storage and Auth clients. Reading
`ctx.supabase` or `ctx.db.$client` builds the full supabase-js client.

`context(request)` resolves a request once: later calls with the same
`refresh` and `cookies` options return the same context, so nested
middleware and several Server Components share it. Calls that pass
`tenant`, `stats`, `pinnedUntil` or `support` build a new one.

By default `context()` never refreshes; run the proxy for that. For routes
that browsers call with cookies outside a proxy, `context(request, { refresh: true })`
refreshes an expiring session; send the new cookies with
`ctx.apply(response)`, which also sets `bs-primary-until` after a write when
read replicas are configured. The Hono, edge and oRPC (`bs.fetchHandler`)
adapters do this for you, and so does
[`handle()`](/docs/frameworks/other#write-an-adapter) in your own. To verify
a bare access token (a WebSocket's, a queue message's), use
`resolveToken(bs, token)`. The supabase-js client is built from the verified
token, so it makes no auth calls and keeps no state between requests.

To build several contexts from one request without verifying the token
again, resolve once and pass the resolution:

```ts
const resolution = await bs.resolve(request);
const ctx = bs.contextFromResolution(resolution, request);
```

When `env.jwksUrl` is set and `auth.jwks` is not, `createServer` fetches the
JWKS right away, so the first request verifies its token without waiting for
it. Pass `prefetchJwks: false` to turn that off; it is off under
`NODE_ENV=test`.

## Explicit identities [#explicit-identities]

```ts
// Service role: bypasses RLS. The actor is `service`, so audit columns say so.
await bs.admin().customers.count();

// A user, with RLS, without their token: jobs, webhooks, support tooling.
await bs.actingAs(userId, { tenant_id: organizationId }).customers.findMany();
```

`actingAs` runs over direct Postgres with the user's claims set for the
transaction, the way PostgREST does it, so policies see
`auth.uid() = userId`. It needs `postgres`. For support work, pass
`{ actor, reason }` as a third argument to record the admin: see
[Impersonation](/docs/auth/impersonation).

`forContext(context)` does the same for a context recorded earlier, such as
`job.context` from the [jobs block](/docs/blocks/jobs#actor-and-tenant). It runs
as the context's user, sets its tenant as `tenant_id` and as the
`better_supabase.tenant` setting, and keeps an impersonating admin in `act`.
It returns a `Result`, which fails with `forbidden` when the context has no
user, so a job never falls back to the service role:

```ts
const db = await bs.forContext(job.context).orThrow();
```

A job has no token, so claims that a custom access token hook adds (a role,
an organization list) are missing. `claimsFor` rebuilds them when the job
runs; `sub`, `role`, `tenant_id` and `act` always come from the context:

```ts
export const bs = createServer(betterSupabase, {
  postgres: createPostgres(),
  claimsFor: async (userId, context) => ({
    user_role: await roleOf(userId, context.tenant),
  }),
});
```

See [Jobs, webhooks and agents without a session](/docs/guides/without-a-session).

`dbFor(auth)` and `supabaseFor(auth)` bind to an auth state you already
have, for example one resolved in a proxy.

## From a verified token to a context [#from-a-verified-token-to-a-context]

When something else already verified the caller (a gateway, a proxy, a queue
message that carries the token), skip a second verification and build the
context from the result. `resolveToken(bs, token)` verifies a bearer token
once and returns the `AuthState`; `bs.contextFor(auth)` turns any
`AuthState` into the same context `bs.context(request)` returns, with `db`,
`supabase` and the caller's claims:

```ts
import { resolveToken } from "better-supabase/server";

const auth = await resolveToken(bs, message.accessToken);
const { db } = bs.contextFor(auth);
await db.customers.findMany();
```

Without a server, pass the claims you verified yourself to `connect`.
`authContext(auth)` builds the context from an `AuthState`; a hand-built one
needs at least `claims`, and `actor` and `tenant` when the `actor()` and
`tenant()` plugins should fill them:

```ts
const db = betterSupabase.connect(supabase, {
  claims,
  actor: { id: claims.sub, kind: "user", role: "authenticated" },
  tenant: claims.tenant_id,
});
```

Services read the caller from `db.$context`, not from the client: `db.$context.claims`
holds the verified claims and `db.$context.actor` the actor, so domain code
never decodes the token again or reaches for `db.$client`.

## Token audience [#token-audience]

`createServer(betterSupabase, { auth: { audience } })` accepts only tokens
whose `aud` claim matches; without it the audience is not checked. Supabase
Auth issues `aud: "authenticated"` to signed-in users. A resource server that
also takes OAuth access tokens issued for its own URL, such as an
[MCP server](/docs/frameworks/mcp), lists both:

```ts
export const bs = createServer(betterSupabase, {
  auth: { audience: ["authenticated", "https://api.example.com/mcp"] },
});
```

`issuer` works the same way for the `iss` claim.

## Shared-secret tokens [#shared-secret-tokens]

[Supabase Lite](/docs/platform/lite) signs tokens with HS256 and no key id.
`createServer(betterSupabase, { backend: "lite" })` verifies those against
`SUPABASE_JWT_SECRET`, checking the signature, `exp`, `nbf`, `aud` and `iss`
locally, and keeps verifying tokens that carry a `kid` through the JWKS.
`liteDriver` names the Lite driver, so `$rpc` on SQLite returns an
`unsupported` error instead of a request.

## Request headers [#request-headers]

`headers` adds headers to every Supabase request made for a caller, for
tracing, rate limits or log fields:

```ts
export const bs = createServer(betterSupabase, {
  headers: (request) => ({ "x-channel": "web" }),
});
```

## Request and correlation ids [#request-and-correlation-ids]

Every context carries a request id and a correlation id, which the
[audit module](/docs/blocks/sql#request-and-correlation-ids) records on each
row change and event. `ctx.db` sends them as the `x-request-id` and
`x-correlation-id` headers, and `ctx.sql` sets them as the transaction-local
`better_supabase.request_id` and `better_supabase.correlation_id` settings.
The request id is the incoming request's `x-request-id` when it is valid, else
a new UUID, kept for every context built from the same `Request`. The
correlation id is the incoming `x-correlation-id`, else the request id.

An id is valid when it has 1 to 128 characters from `A-Z`, `a-z`, `0-9` and
`. _ : ; , @ / + = -`; anything else is dropped. The ids are log metadata: a
client can send any value, so never use them to decide access.

`requestIds` changes the header names, ignores the incoming headers, or turns
the ids off:

```ts
export const bs = createServer(betterSupabase, {
  requestIds: {
    requestHeader: "x-vercel-id",
    correlationHeader: "x-correlation-id",
    incoming: true,
    generate: true,
  },
});
```

| Option              | Default            | What it does                                                     |
| ------------------- | ------------------ | ---------------------------------------------------------------- |
| `requestHeader`     | `x-request-id`     | the header the request id is read from and sent as               |
| `correlationHeader` | `x-correlation-id` | the header the correlation id is read from and sent as           |
| `incoming`          | `true`             | reuse valid ids from the incoming request's headers              |
| `generate`          | `true`             | generate a request id when the incoming request has no valid one |

`requestIds: false` reads and generates nothing. Set the audit module's
`requestIdHeader` and `correlationIdHeader` options to the same header names.
A job without a request passes its own ids, and the `headers` option still
wins over the id headers:

```ts
const ctx = server.contextFor(auth, {
  requestId: job.id,
  correlationId: job.data.correlationId,
});
```

`ctx.requestId` and `ctx.correlationId` hold the ids, for an
[audit event](/docs/blocks/audit#recording-events) written with a
service-role transport or a log line.

## Active tenant [#active-tenant]

Apps that pick the tenant from the URL or a profile, rather than a claim,
pass `tenant`. It returns the tenant id for a request, or `undefined`. The
result becomes `context.tenant` for the [`tenant()` plugin](/docs/plugins/tenant),
the `better_supabase.tenant` setting for `ctx.sql` and the `x-bs-tenant`
header (`TENANT_HEADER`) for `ctx.db`, which `current_tenant_id()` reads with
`sql.modules.access.activeTenant: 'resolver'`. See
[The active tenant](/docs/blocks/access#the-active-tenant).

```ts
export const bs = createServer(betterSupabase, {
  tenant: (request) => request.headers.get("x-organization-id") ?? undefined,
});

const ctx = await bs.context(request, { tenant: organizationId });
```

`context(request, { tenant })` overrides the resolver for one call.
`contextFromResolution` can't wait for an async resolver, so it throws unless
you pass `{ tenant }`. Under `createNext`, Server Components and actions
have no request URL; pass the tenant from route params with
`bs.context({ tenant })` or `bs.cached({ tenant })` (see
[The active tenant](/docs/blocks/access#the-active-tenant)).

## Machine callers [#machine-callers]

Secret keys sent as `apikey` are rejected unless you enable them. Give a list
of key names to accept only some of the keys in `SUPABASE_SECRET_KEYS`:

```ts
// SUPABASE_SECRET_KEYS={"default":"sb_secret_…","cron":"sb_secret_…"}
export const bs = createServer(betterSupabase, { auth: { secret: ["cron"] } });
// ctx.auth is { kind: 'service', keyName: 'cron' }; guard with allow: ['service']
```

`secret: true` accepts any configured key. The admin client is created on the
first `admin()` call, so servers that never use it don't need a secret key.

For a service caller `ctx.db` runs with the secret key, so RLS doesn't apply,
and `ctx.sql` is `undefined`: there is no user to run as. Use `bs.admin()`
for privileged repository work, or `bs.actingAs(userId)` to run over Postgres
as a particular user.

Pass `fetch` (for example `tracedFetch()` from `better-supabase/otel`) to send
every PostgREST and supabase-js request through it. Auth refreshes use
`auth.fetch`.

# Session cookies

> Read and write the @supabase/ssr cookie format from any framework.

Source: https://bettersupabase.com/docs/auth/sessions

The browser session lives in `sb-<project>-auth-token`, exactly as
`@supabase/ssr` writes it: `base64-` prefixed JSON, split into `.0`, `.1`, …
chunks when it is larger than 3180 characters. better-supabase uses the
chunker and encoders from `@supabase/ssr` itself, so both read each other's
cookies.

The primitives in `better-supabase/ssr` are framework-agnostic; adapters for
SvelteKit, Nuxt, TanStack Start and React Router build on them.

```ts
import {
  parseCookies,
  readSession,
  sessionCookieName,
  writeSession,
} from "better-supabase/ssr";

const name = sessionCookieName(env.url);
const cookies = parseCookies(request.headers.get("cookie"));
const session = readSession(cookies, name); // null when absent or corrupted

const writes = writeSession(cookies, name, nextSession); // or null to sign out
```

`writeSession` returns every cookie to set, including expiries for chunks
the new value no longer needs. Chunks left over from a partial write (a mix
of two generations) read as no session rather than as a broken one.

Whenever you send session cookies, send `AUTH_CACHE_HEADERS` too.
`resolution.apply(response)` does both.

## Encoding [#encoding]

`@supabase/ssr` 0.12 writes the cookie in one of two shapes, set with
`cookies.encode`:

| `encode`                    | The cookie holds                         | The user object lives in                                           |
| --------------------------- | ---------------------------------------- | ------------------------------------------------------------------ |
| `user-and-tokens` (default) | the access token, refresh token and user | the cookie                                                         |
| `tokens-only`               | the access token and refresh token       | `auth.userStorage` (localStorage in the browser, memory elsewhere) |

`tokens-only` keeps the cookie small, often under one chunk, and is the
recommended setting for new apps. `readSession` reads both shapes, so you can
switch at any time; a `tokens-only` session has no `user` field, and
`sessionEncoding(session)` tells you which shape a cookie has. Pass the same
value to `writeSession` and to the browser client:

```ts
const writes = writeSession(cookies, name, nextSession, cookieOptions, {
  encode: "tokens-only",
});
```

```ts title="src/lib/supabase/client.ts"
import { createClient } from "better-supabase/client";

export const bs = createClient(betterSupabase, {
  env: { url, publishableKey },
  cookies: { encode: "tokens-only" },
});
```

`createClient` also passes `auth.userStorage` through to `@supabase/ssr` when
you want the user object somewhere other than localStorage. Keep `encode` the
same on both sides: a server on `user-and-tokens` writes the user object back
into the cookie, and a browser client on `user-and-tokens` that reads a
`tokens-only` cookie throws on `session.user`.
[BS412](/docs/cli/doctor#bs412) reports a mismatch in your sources.

Nothing in better-supabase depends on the user object in the cookie.
`bs.session()`, `useSession()` and the browser client's `auth.current()` read
the user's id, email, role and metadata from the verified (on the server) or
decoded (in the browser) access token claims, so they are the same with either
encoding. That is also why the claims, not `getUser()`, are the check to use
on every request: they verify locally without a call to the Auth server. Call
`supabase.auth.getUser()` only in flows that must see a user who was deleted
or banned since the token was issued, such as changing a password or deleting
an account.

## Moving the cookie to another domain or path [#moving-the-cookie-to-another-domain-or-path]

When a deploy changes the cookie `domain` or `path`, the old cookies stay in
the browser at the old scope, where the new code never overwrites them.
`clearSessionAtScopes` returns `Max-Age=0` writes for every chunk of the
session cookie at each old scope, like `clearAuthCookiesAtScopes` from
`@supabase/ssr`:

```ts
import { clearSessionAtScopes, serializeCookie } from "better-supabase/ssr";

for (const write of clearSessionAtScopes(cookies, name, [
  { domain: ".old.example.com" },
  { path: "/app" },
])) {
  response.headers.append("Set-Cookie", serializeCookie(write));
}
```

List only the scopes earlier deploys used: a scope equal to the current one
clears the live session. Browsers ignore writes for a domain the host doesn't
own, so listing extra domains is safe. Append these writes as raw `Set-Cookie`
headers, since a cookie API keyed by name (such as `response.cookies.set` in
Next.js) keeps only the last write for each name, and don't pass them to
`applyCookieWrites`, which would drop the current session from the request.

## Ended sessions in your own proxy [#ended-sessions-in-your-own-proxy]

An access token stays valid until it expires, even after its session is
signed out elsewhere, revoked or deleted. `bs.proxy({ endedSession })` in
Next.js checks for that on page loads. A proxy or middleware that runs its
own `@supabase/ssr` client uses the same check through `sessionStatus` and
`clearSessionCookies` from `better-supabase/ssr` or `better-supabase/server`:

```ts
import { clearSessionCookies, sessionStatus } from "better-supabase/ssr";

const status = await sessionStatus(request, { url, publishableKey });
if (status === "ended") {
  const response = NextResponse.redirect(
    new URL("/login?reason=session_ended", request.url),
  );
  return clearSessionCookies(request, response, { url });
}
```

`sessionStatus` takes a request or an access token. From a request it reads
the bearer token, then the session cookie (`cookieName` overrides
`sb-<project>-auth-token`). It asks Auth (`GET /auth/v1/user`) and returns:

| Status      | When                                                                                          |
| ----------- | --------------------------------------------------------------------------------------------- |
| `"ended"`   | Auth answers 401 or 403: the session was signed out, revoked or deleted                       |
| `"banned"`  | Auth answers 401 or 403 with `error_code: "user_banned"`: an admin banned the account         |
| `"active"`  | Auth answers 2xx                                                                              |
| `"unknown"` | No token, an expired token (refresh it first), a network failure, a timeout or another status |

Send `"banned"` to a page that explains the suspension instead of the sign-in
page, and clear the cookies the same way as for `"ended"`. Auth reports
`user_banned` only while the session still exists: `suspendAccount` keeps the
sessions for that reason, and a ban whose sessions were deleted reads as
`"ended"` ([Suspending an account](/docs/auth/account-deletion#suspending-an-account)). When a refresh
fails because the user is banned, `refreshSession` returns
`{ ok: false, reason: "rejected", code: "user_banned" }`, and `code` carries
the Auth `error_code` of any rejected refresh. Treat `"unknown"` as active, so an Auth outage never signs anyone out. Each
call costs one Auth request, so run it on page loads only, not on prefetches
or assets (`shouldCheckSession` from `better-supabase/next` makes that
choice for Next.js). `fetch`, `timeoutMs` (5000 by default) and `now` are
optional.

`clearSessionCookies(request, response, { url, cookieName?, cookie? })`
appends `Max-Age=0` writes for every chunk of the session cookie the request
carries, plus `AUTH_CACHE_HEADERS`, and returns the same response. Its
headers must be mutable: `NextResponse.redirect` and `NextResponse.next` are,
`Response.redirect` is not. Pass `cookie` with the `domain` or `path` the
session was written at when it is not the default.

## Claims in the session [#claims-in-the-session]

The access token inside the cookie carries every claim your custom access
token hook adds, so the session cookie grows with them and every request
sends them twice (cookie and `Authorization` header). Keep ids and roles in
the token, validate and type them with
[`betterSupabase.claims(schema)`](/docs/auth#typed-claims), and let
`better-supabase doctor --as <user id>` warn when they pass 2 KB, and when the
claims an [authorization provider's](/docs/extending/authorization-providers)
hook budgets pass that budget ([BS405](/docs/cli/doctor#bs405)).

# Access contract

> One permission check for RLS policies and SQL modules, over a role list, your own role tables, an authorization provider or functions you already have.

Source: https://bettersupabase.com/docs/blocks/access

The `access` SQL module gives policies and the other SQL modules one way to
ask "may the caller do this": `can(scope, id, permission)`. The functions behind
it come from the access model you pick in `sql.modules.access`, so the same policies
work over a fixed role list, a role and permission catalog you already have,
an authorization provider, or functions your app already wrote.

```bash
better-supabase sql add access
```

```sql
create policy "members read" on public.projects for select to authenticated
  using (organization_id in (select better_supabase.tenant_ids_with('projects.read')));

create policy "admins update" on public.projects for update to authenticated
  using (organization_id in (select better_supabase.tenant_ids_with('projects.update')))
  with check (organization_id in (select better_supabase.tenant_ids_with('projects.update')));

create policy "admins delete the organization" on public.organizations for delete to authenticated
  using (
    id = (select better_supabase.current_tenant_id())
    and (select better_supabase.can('organization', better_supabase.current_tenant_id(), 'organizations.delete'))
  );
```

`tenant_ids_with()` returns a set, so Postgres runs it once per statement
instead of once per row. Use it in `using` and `with check` whenever the
tenant comes from the row. Keep `can()` for checks whose arguments are
constants for the statement, and wrap the call in `(select ...)` so Postgres
evaluates it once.

## The contract [#the-contract]

| Function                                      | Returns                                                                  |
| --------------------------------------------- | ------------------------------------------------------------------------ |
| `can(scope, scope_id, permission)`            | whether the caller holds `permission` in that tenant, or on the platform |
| `tenant_ids_with(permission)`                 | the tenants where the caller holds `permission`                          |
| `is_platform(permission)`                     | whether the caller holds a platform permission (support staff, admins)   |
| `can_user(user, scope, scope_id, permission)` | the same check for another user (service role only)                      |
| `can_assign(tenant, role)`                    | whether the caller may hand out `role` in that tenant                    |
| `permission_claims(user)`                     | a `jsonb` claim for your access token hook                               |

Permission keys are dotted strings. A granted `*` matches every key, and
`members.*` matches every key that starts with `members.`. SQL modules check
the keys in their `permissions` config, so `sql.modules.invitations.permissions.invite`
can be `organization.members.invite` in an app that already uses that name:

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      access: {},
      invitations: { permissions: { invite: "organization.members.invite" } },
    },
  },
});
```

Keys follow one scheme, `<area>.<verb>`, with `read` for viewing. Platform-wide actions are checked with `is_platform()`. An action whose default is another action's key follows that key as configured, unless you set the action itself.

| Module             | Action and default key                                                                                                                                                                                                                   |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `organizations`    | `update`: `organization.update`, `delete`: `organization.delete`, `removeMember`: `members.remove`, `suspendMember`: the `removeMember` key, `updateRole`: `members.update_role`, `transferOwnership`: `organization.transfer_ownership` |
| `invitations`      | `invite`: `members.invite`, `revoke` and `view`: the `invite` key; `invitePlatform`: `platform.invite`                                                                                                                                   |
| `audit`            | `view`: `audit.read`, `viewAll` (platform): the `view` key                                                                                                                                                                               |
| `support-sessions` | `start`: `support.start`, `view`: `support.read`, `revoke`: `support.revoke`                                                                                                                                                             |
| `notifications`    | `send`: `notifications.send`, `read`: `notifications.read`                                                                                                                                                                               |
| `webhooks-out`     | `manage`: `webhooks.manage`, `view`: `webhooks.read`                                                                                                                                                                                     |
| `billing`          | `read`: `billing.read`, `manage`: `billing.manage`, `viewAll` (platform): the `read` key                                                                                                                                                 |
| `flags`            | `manage` (platform): `flags.manage`                                                                                                                                                                                                      |
| `waitlist`         | `manage` (platform): `waitlist.manage`, `invite`: the `invitations` module's `invite` key                                                                                                                                                |

## Models [#models]

`sql.modules.access.model` picks where permissions come from. Every model writes the
same contract, so you can change models without touching your policies.

### roles (the default) [#roles-the-default]

Roles and their keys live in the config. The `tenant` module's memberships
table holds each member's role, and its role check follows the names here.
Platform permissions come from the `platform_permissions` claim
(`platformClaim` renames it), an array your access token hook writes.

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      access: {
        roles: {
          owner: ["*"],
          admin: ["organization.*", "members.*", "billing.*"],
          member: ["organization.read", "projects.*"],
        },
      },
    },
  },
});
```

Without `roles`, the defaults are `owner` (`*`), `admin`, `member` and
`viewer`. `can_assign()` lets a member hand out a role only when their own role
holds every key of it, so an admin can't make someone an owner.

### catalog [#catalog]

Roles, permissions and their links are rows in tables. A per-tenant override
table can grant or deny a key for one role in one tenant (a deny wins), and a
platform table assigns platform roles to users. In `managed` mode the block
creates the tables; in `adopt` mode you point it at yours:

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      tenant: {
        mode: "adopt",
        tables: { memberships: "public.organization_members" },
        columns: {
          memberships: {
            tenant: "organization_id",
            role: "role_id",
            lastUsedAt: null,
          },
        },
      },
      access: {
        mode: "adopt",
        model: "catalog",
        tables: {
          roles: "public.roles",
          permissions: "public.permissions",
          rolePermissions: "public.role_permissions",
          overrides: "public.organization_role_overrides",
          platformAssignments: "public.user_roles",
        },
        columns: {
          roles: { key: "slug" },
          overrides: { tenant: "organization_id" },
        },
      },
    },
  },
});
```

`null` marks an optional table or column you don't have. With
`overrides: null`, role defaults are the only source.

### provider [#provider]

With an [authorization provider](/docs/extending/authorization-providers) in
the config's `authorization` key, `can()` and `tenant_ids_with()` call the
provider's `idsWith` template at its `tenantScope`, and `platform_can()` calls
its `isPlatform`. The id type is that scope's `idType`.

```ts title="better-supabase.config.ts"
export default defineConfig({
  authorization: authorizationProvider(),
  sql: {
    modules: {
      organizations: {},
      invitations: {},
      access: { model: "provider" },
    },
  },
});
```

`sql add` refuses, instead of guessing, when the config has no
`authorization`, or when `sql.modules.access.idType` disagrees with the tenant
scope's `idType`.

The provider's functions check role and scope, never row conditions. Every
permission key a SQL module checks must be `sqlComplete: true` in the
provider's `permissions`, or `sql add` and [doctor](/docs/cli/doctor#bs411)
stop. Map an action to another key with `sql.modules.<module>.permissions`.

`can_assign()` calls the provider's `canAssign`, and
`sql.modules.access.functions.canAssign` replaces it. Without either, only the
service role assigns roles, unless the `tenant` module is installed, and doctor
BS411 warns.

`can_user()` and `member_can()` answer for another user through `idsWithFor`
and `isPlatformFor`. A provider without them answers for the caller only:
called for another user, they raise SQLSTATE `0A000` instead of returning
`null`. `can_assign_as(user, tenant, role)` checks a stored user's authority,
such as the inviter's when an invitation is accepted, through `canAssignFor`;
`sql.modules.access.functions.canAssignFor` replaces it with your own template
(`{user}`, `{tenant}` and `{role}`), under the provider and custom models.

### Blocks next to a provider [#blocks-next-to-a-provider]

A provider already answers permission questions and may guard memberships, so
some module checks change under the provider model:

| Module          | Check                                        | Under `provider`                                                                                         |
| --------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `organizations` | Role ceiling trigger on memberships          | Kept, or dropped with `assignmentGuard: "external"` when the provider's assignment rules guard the table |
| `organizations` | Owner check (`ownerInvariant`)               | Kept; turn it off when the provider already keeps an owner                                               |
| `invitations`   | Inviter still holds the permission at accept | Runs with `idsWithFor` and `isPlatformFor`; skipped without them                                         |
| `invitations`   | Inviter may still assign the role at accept  | Runs with `canAssignFor`; skipped without it                                                             |
| `invitations`   | Platform invitations                         | Need `invitations.options.platformRoles`; the inviter is checked when inviting                           |
| `notifications` | Each recipient may read the notification     | Runs with `idsWithFor`; without it, the sender and `notification_audience` decide                        |
| `tenant`        | Role names on memberships                    | Read through the provider's `roleSources`                                                                |
| `access`        | Disabled tenants and users                   | Read from the provider's `suspension`; an explicit `sql.modules.access.disabled` wins per subject        |

### custom [#custom]

Your app already has the functions. Give the contract as SQL templates with
`{scope}`, `{id}`, `{permission}`, `{user}`, `{tenant}` and `{role}`
placeholders. `can`, `tenantIdsWith`, `isPlatform` and `canAssign` are required.
`member_can(user, tenant, permission)` and `can_assign_as(user, tenant, role)`
are required when the `organizations` or `invitations` modules are installed:
those modules call them for a stored user, not only `auth.uid()`.
`canAssignFor` (`{user}`, `{tenant}`, `{role}`) is the custom body of
`can_assign_as`. Without it, write `can_assign_as` yourself so an invitation
accept can check the inviter again.

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      access: {
        model: "custom",
        functions: {
          can: "public.authorize({scope}, {id}, {permission})",
          tenantIdsWith: "public.organizations_with({permission})",
          isPlatform: "public.is_staff({permission})",
          canAssign: "public.can_grant_role({tenant}, {role})",
        },
      },
    },
  },
});
```

With `mode: "custom"` instead, the block writes nothing at all and you write the
contract functions under those names yourself. `better-supabase sql print
access` lists their signatures, and [doctor](/docs/cli/doctor#bs307) checks
them.

## Membership roles stored as ids [#membership-roles-stored-as-ids]

An adopted memberships table often stores a foreign key to a roles table
instead of the role name. `sql.modules.tenant.options.roleThrough` names that
table, its key column and the column with the role name:

```ts title="better-supabase.config.ts"
sql: {
  modules: {
    tenant: {
      mode: "adopt",
      tables: { memberships: "public.team_members" },
      columns: { memberships: { tenant: "team_id", role: "role_id" } },
      options: {
        roleThrough: { table: "public.team_roles", id: "id", column: "key" },
      },
    },
  },
},
```

Every model then reads role names through the table: `has_organization_role`,
`member_organization_ids` and the memberships claim compare names, the roles
model looks up each name's permissions, and `can_assign` receives the name.
The organizations and invitations modules accept a role name or id, store
the id, and refuse a role the table doesn't have (`ORGANIZATION_ROLE_UNKNOWN`,
`INVITATION_ROLE_UNKNOWN`). A managed memberships table stores names, so the
option is for `mode: "adopt"` only.

When the roles table also holds tenant custom roles, whose keys can repeat
across tenants, add its tenant column as `tenant` (`roleThrough: { table,
id, column, tenant: "organization_id" }`); under the catalog model, map
`sql.modules.access.columns.roles.tenant` instead. A key then resolves among
the tenant's own roles and the shared ones (no tenant), the tenant's first,
and an id of another tenant's role is unknown in this one.

When the roles table also holds roles of another kind, such as the platform
roles the invitations module's `platformRoles.through` points at, name the
tenant roles with `where`, a condition on the roles row `{row}`
(`roleThrough: { table, id, column, where: "{row}.scope = 'organization'" }`).
Every lookup of a role name or id for a membership keeps to those rows,
including SSO and waitlist joins: organization invitations,
`update_invitation` and accept refuse any other role with
`INVITATION_ROLE_UNKNOWN`, and `update_member_role` and ownership transfer
with `ORGANIZATION_ROLE_UNKNOWN`. When the table is
also `platformRoles.through`'s, `where` is required on both, and
[doctor BS324](/docs/cli/doctor#bs324) reports the side that lacks it.

With `where` set, the tenant module also puts a `bs_role_scope` trigger on
the memberships table, so a direct write can't store a role outside it
either: an insert or a role update through a client policy, the service role
or an admin connection fails with `MEMBERSHIP_ROLE_SCOPE` (SQLSTATE `23514`).
The invitations module does the same on `platformRoles.table` with
`platformRoles.through.where` (`PLATFORM_ROLE_SCOPE`). Both triggers check new
writes only; rows stored before you set `where` stay until they change.
Removing `where` drops the trigger.

Under the provider model, `sql add` reads the setting from the provider's
`roleSources`: when one of them is the adopted memberships table, the tenant
module uses its `role.through` lookup. An explicit `roleThrough` wins; set one
to add `where`, which `roleSources` doesn't carry.

## References within one tenant [#references-within-one-tenant]

A foreign key checks that a referenced row exists, not that it belongs to
the same tenant, so a task could point at another organization's project.
`sql.modules.tenant.options.sameTenant` lists the references that must stay
inside the row's tenant, and the tenant module puts a
`bs_same_tenant_<column>` trigger on each table. Every insert, and every
update of the column or the tenant column, then needs a referenced row with
the same tenant, whoever writes it (a client policy, the service role or an
admin connection); otherwise it fails with `TENANT_MISMATCH` (SQLSTATE
`23514`).

```ts title="better-supabase.config.ts"
sql: {
  modules: {
    tenant: {
      options: {
        sameTenant: [
          { table: "public.tasks", column: "project_id", references: "public.projects" },
          {
            table: "public.task_settings",
            column: "template_id",
            references: {
              table: "public.templates",
              column: "id",
              tenant: "organization_id",
              where: "{row}.type = 'task'",
            },
          },
        ],
      },
    },
  },
},
```

| Field               | Default                       | Meaning                                                    |
| ------------------- | ----------------------------- | ---------------------------------------------------------- |
| `table`             | required                      | the table holding the reference, `table` or `schema.table` |
| `column`            | required                      | the column holding the referenced row's key                |
| `tenant`            | the memberships tenant column | the row's tenant column                                    |
| `references.table`  | required                      | the referenced table; a string is the table alone          |
| `references.column` | `id`                          | the referenced table's key column                          |
| `references.tenant` | the row's tenant column       | the referenced table's tenant column                       |
| `references.where`  | none                          | a condition the referenced row `{row}` must also meet      |
| `match`             | none                          | `{ <column>: <referenced column> }` pairs that must match  |
| `through`           | none                          | the tables that dotted `match` keys read through           |

`match` ties the reference to other columns of the row being written. An
asset on a job must belong to the job's customer, and a material on a quote
line must belong to the quote's customer:

```ts
sameTenant: [
  {
    table: "public.jobs",
    column: "asset_id",
    references: "public.assets",
    match: { customer_id: "customer_id" },
  },
],
```

Each key is a column of the row and each value a column of the referenced
row, and the two must hold the same value. The comparison is `is not
distinct from`, so a job without a customer accepts only an asset without
one. An update of a `match` column runs the check again, so moving the job
to another customer fails while its asset still belongs to the first one.

A `match` key can also read a column through another reference of the row,
written `<reference column>.<column>`. A quote asset must belong to the
quote's customer, and the asset row holds `quote_id`, not the customer:

```ts
sameTenant: [
  {
    table: "public.quote_assets",
    column: "asset_id",
    references: "public.assets",
    match: { "quote_id.customer_id": "customer_id" },
    through: { quote_id: "public.quotes" },
  },
],
```

`through` names the table each dotted key's reference column points at, as
`"schema.table"` or `{ table, column }` when its key is not `id`. The check
reads `customer_id` from the quote that `quote_id` points at and compares it
with the asset's `customer_id`, with `is not distinct from` as above, so a
row without a quote accepts only an asset without a customer. An update of
`quote_id` runs the check again. A later change to the quote's customer is
not checked here; give that table its own entry or trigger if it can change.

Every trigger calls the one function `better_supabase.same_tenant()` with the
entry as its arguments. A null reference passes, so a nullable column stays
optional, and the foreign key still reports a row that does not exist. The
check runs on the referencing table only: it does not stop a change to the
tenant column of a referenced row. Rows stored before you add an entry are
checked when they change. The triggers are written in managed and adopt
mode; in custom mode the module writes no SQL.

Composite foreign keys are the trigger-free alternative: a unique
`(id, organization_id)` on the referenced table and a foreign key
`(project_id, organization_id) references projects (id, organization_id)`
on the referencing one. Postgres then checks both directions, including a
referenced row moving tenants, at the cost of an extra unique index per
table and a tenant column in every key. Use `sameTenant` when the tables
already exist and their keys should stay as they are, or when the
reference also needs a `where` condition.

## Disabled tenants and users [#disabled-tenants-and-users]

`sql.modules.access.disabled` names columns that switch a tenant or a user off when
they are set, such as `disabled_at`. A disabled tenant or user gets no
permissions, and `membership_claims()` leaves them out of the token.

```ts
sql: {
  modules: {
    access: {
      disabled: {
        tenant: "public.organizations.disabled_at",
        user: "public.profiles.disabled_at",
        userKey: "user_id",
      },
    },
  },
},
```

The tenant column is matched against the table's `id`. `userKey` names the
column matched against the user id; it defaults to `id`.

The checks are `better_supabase.tenant_disabled(id)` and
`better_supabase.user_disabled(user_id)`. The `access` module's file defines
them; the `tenant` module's file defines them only when `access` isn't
installed (or runs in custom mode), so each function lives in one schema file.

Either subject also takes an active row: a table, the id column, and a
nullable `disabledAt` column, a `status` column with its `active` values, or
both. Only a row that matches counts as active, so a tenant or user without a
row is disabled.

```ts
disabled: {
  tenant: {
    table: "public.organizations",
    id: "id",
    status: "state",
    active: ["active", "trial"],
  },
  user: { table: "public.profiles", id: "id", disabledAt: "banned_at" },
},
```

Under the provider model, `sql add` and `sql sync` read both from the
provider's `suspension`. A subject set in
`sql.modules.access.disabled` keeps its own setting. The data-lifecycle
module disables a tenant awaiting deletion by setting the `disabledAt`
column, so a tenant row with only a `status` column is not switched off
during the grace period.

## Suspended memberships [#suspended-memberships]

A suspended membership keeps its role but grants nothing in that tenant.
The `tenant` module reads it from `memberships.disabledAt`, a nullable
timestamp column on the memberships table: `member_can`, `tenant_ids_with`,
`member_permissions`, `can_assign_as`, `permission_claims`,
`member_organization_ids`, `has_organization_role` and `membership_claims`
skip a membership while the column is set. The other memberships of the same
user keep working.

A managed memberships table gets the `disabled_at` column. An adopted table
opts in by mapping the column, so an existing table without it keeps working:

```ts
sql: {
  modules: {
    tenant: {
      mode: "adopt",
      tables: { memberships: "public.organization_users" },
      columns: { memberships: { disabledAt: "disabled_at" } },
    },
  },
},
```

Under the `provider` and `custom` models, `member_can` and `tenant_ids_with`
also refuse a tenant whose membership row is suspended, on top of what the
provider or your functions decide. The organizations block suspends and
resumes members with [`suspend_member` and `resume_member`](/docs/blocks/organizations#suspending-members).

## The active tenant [#the-active-tenant]

`current_tenant_id()` and the [`tenant()` plugin](/docs/plugins/tenant) need
the tenant a request works in. `sql.modules.access.activeTenant` says where it comes
from.

| Source                   | Reads                                                                             |
| ------------------------ | --------------------------------------------------------------------------------- |
| `'claim'`                | the `claims.tenant` claim, at the top level or in `app_metadata`                  |
| `'resolver'` (default)   | the tenant the server resolved for the request, then the claim                    |
| `{ profileColumn, key }` | the claim, then a profile column such as `public.profiles.active_organization_id` |

With `'resolver'`, pass `tenant` to the server. It runs once per request, and
the tenant it returns becomes `context.tenant` for the `tenant()` plugin, the
`better_supabase.tenant` setting for `ctx.sql`, and the `x-bs-tenant` header
for `ctx.db`. `current_tenant_id()` returns it only while the caller is a
member, so a tampered header or slug grants nothing.

```ts title="src/lib/supabase/server.ts"
import "server-only";
import { createServer } from "better-supabase/server";

import { betterSupabase } from "./index";

export const bs = createServer(betterSupabase, {
  tenant: async (request, auth) => {
    const slug = new URL(request.url).pathname.split("/")[2];
    return slug ? organizationIdForSlug(slug) : undefined;
  },
});
```

Under `createNext`, Server Components, actions and private caches have no
request URL: the resolver gets a request with the incoming headers and a
placeholder URL. Pass the tenant from the route params instead. It goes
where the resolver's result goes, so `current_tenant_id()` and the
`tenant()` plugin check it the same way, and a tenant the caller doesn't
belong to grants nothing:

```tsx title="src/app/[organizationId]/customers/page.tsx"
export default async function Customers({
  params,
}: PageProps<"/[organizationId]/customers">) {
  const { organizationId } = await params;
  const { db } = await bs.context({ tenant: organizationId });
  const customers = await db.customers.findMany().orThrow();
  return <CustomerList customers={customers} />;
}
```

`bs.cached({ tenant })` does the same inside `'use cache: private'`. Take
the tenant as an argument of your function, because Next.js keys the
cache entry on the arguments:

```ts
async function getCustomers(organizationId: string) {
  "use cache: private";
  const { db } = await bs.cached({ tenant: organizationId });
  return db.customers.findMany().orThrow();
}
```

Actions take it from their validated input with
`bs.action({ input, tenant: (input) => input.organizationId }, fn)`.
`bs.route()` and `bs.context(request)` pass the real request, so a resolver
that reads the URL works there.

A route that already knows the tenant passes it directly:
`bs.context(request, { tenant })`. `contextFromResolution` runs only
synchronous resolvers; pass `{ tenant }` to it when yours is async.

With `'claim'`, [doctor `--as <user id>`](/docs/cli/doctor#bs308) calls your
access token hook and warns when the claims it returns have no tenant.

## Claims for the access token hook [#claims-for-the-access-token-hook]

The block never owns your access token hook. It gives you claim builders to
call from it: `membership_claims(user)` from the `tenant` module and
`permission_claims(user)` from this one.

```sql
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb language plpgsql stable set search_path = '' as $$
declare
  user_id uuid := (event ->> 'user_id')::uuid;
begin
  event := jsonb_set(event, '{claims,memberships}', better_supabase.membership_claims(user_id));
  return jsonb_set(event, '{claims,permissions}', better_supabase.permission_claims(user_id));
end $$;
```

`sql.modules.tenant.options.claimFormat` picks the memberships claim's form:
`'array'` (the default, `[{ scope, id, roles }]`) or `'map'`
(`{ "<tenant id>": "<role>" }`).

# Agents

> Saved assistants with their own instructions, model, tools, connectors and knowledge, shared in an organization or a public store with installs and ratings.

Source: https://bettersupabase.com/docs/blocks/agents

The `agents` block stores assistants that users build and share. An agent
has a slug, instructions, an optional model, the tools and connectors it
may use, the knowledge scopes it searches and starter prompts.

```bash
better-supabase sql add agents   # adds tenant and access as well
```

| Table            | Holds                                                    |
| ---------------- | -------------------------------------------------------- |
| `agents`         | One agent per organization and slug, with its visibility |
| `agent_installs` | Which users installed an agent                           |
| `agent_ratings`  | One rating (1 to 5) and comment per agent and user       |
| `agent_skills`   | Skill references an agent loads from a provider          |

An agent starts private: only its owner sees it. Publishing it makes it
visible to the organization's members (`organization`) or to every
signed-in user (`public`). Moderators see and change every agent in the
organization.

| Permission    | Lets a member                 | Default roles              |
| ------------- | ----------------------------- | -------------------------- |
| `ai.read`     | see the organization's agents | `owner`, `admin`, `member` |
| `ai.create`   | create agents                 | `owner`, `admin`, `member` |
| `ai.share`    | publish an agent              | `owner`, `admin`, `member` |
| `ai.moderate` | change or delete any agent    | `owner`, `admin`           |

Rename the keys with `sql.modules.agents.permissions.read`, `.create`,
`.publish` and `.moderate`. The `maxInstructions` option (100,000
characters by default) limits the instructions.

## Server [#server]

```ts title="lib/agents.ts"
import { createAgents, rpcTransport } from "better-supabase/blocks/agents";

export const agentsFor = (supabase: SupabaseClient) =>
  createAgents({ transport: rpcTransport(supabase) });
```

```ts
const agent = await agents
  .create(organizationId, {
    slug: "release-notes",
    name: "Release notes",
    instructions: "Write release notes from the merged pull requests.",
    tools: ["search_knowledge"],
    knowledgeScopes: [{ scope: "agent" }],
  })
  .orThrow();
await agents.publish(agent.id, "organization").orThrow();
```

| Method                         | Does                                                      |
| ------------------------------ | --------------------------------------------------------- |
| `create`, `update`, `remove`   | Change the caller's agents                                |
| `get`, `bySlug`                | Read one agent the caller can see                         |
| `list(organizationId, filter)` | `store` (published), `mine` (owned) or `installed`        |
| `publish(id, visibility)`      | Set `private`, `organization` or `public`                 |
| `install`, `rate`              | Install or uninstall, and rate from 1 to 5 with a comment |
| `setSkills`                    | Replace the skill references                              |

Each agent carries `installCount`, `ratingCount`, the average `rating`,
and for the caller `installed` and `myRating`. A slug that is taken in the
organization fails with a `conflict` error.

## AI SDK [#ai-sdk]

[`better-supabase/ai-sdk/agents`](/docs/ai-sdk/agents) turns an agent into
a `ToolLoopAgent` with its tools, approvals and knowledge search.

# AI cache

> Model responses cached by a hash of the request, with a TTL, a size cap, per-tenant clearing and an hourly purge job.

Source: https://bettersupabase.com/docs/blocks/ai-cache

The `ai-cache` block keeps model responses so a repeated request is answered
from Postgres instead of the provider. The key is a SHA-256 of whatever the
application decides makes two requests equal, usually the model, the prompt
and the settings. The [AI SDK cache middleware](/docs/ai-sdk/cache) builds
the key and replays a cached stream; this page covers the block itself.

```bash
better-supabase sql add ai-cache
```

| Table              | Holds                                                                          |
| ------------------ | ------------------------------------------------------------------------------ |
| `ai_cache_entries` | The key, the tenant, `kind`, `model`, the JSON value, hits and the expiry time |

Entries hold prompts and answers, so only the service role reads or writes
them: no member sees another member's cached answer through the Data API.
Create the block with a service transport.

| Option     | Default           | Sets                                                         |
| ---------- | ----------------- | ------------------------------------------------------------ |
| `maxTtl`   | 604,800 (7 days)  | The longest TTL in seconds; a longer `ttl` is cut to it      |
| `maxBytes` | 1,048,576 (1 MiB) | The largest value; `set` fails with `invalid_input` above it |

## Server [#server]

```ts title="lib/ai-cache.ts"
import "server-only";

import {
  cacheKey,
  createAiCache,
  rpcTransport,
} from "better-supabase/blocks/ai-cache";

export const aiCache = createAiCache({
  transport: rpcTransport(bs.admin().$client, { schema: "api" }),
  schema: "api",
});
```

```ts
const key = await cacheKey({
  model: "openai/gpt-5-mini",
  prompt,
  temperature: 0,
});
const cached = await aiCache.get<string>(key).orThrow();
if (cached === undefined) {
  const answer = await summarize(prompt);
  await aiCache
    .set(key, answer, {
      ttl: 3600,
      organizationId,
      kind: "generate",
      model: "openai/gpt-5-mini",
    })
    .orThrow();
}
```

`cacheKey(parts)` sorts object keys before hashing, so `{ a, b }` and
`{ b, a }` give the same key. `get` counts a hit and returns `undefined` for
a missing or expired entry. `clear({ organizationId })` or
`clear({ model })` deletes a tenant's or a model's entries, and the tenant
lifecycle deletes a tenant's entries with its other rows.

## Purge job [#purge-job]

Expired entries are never returned, but they stay in the table until the
purge job deletes them. Schedule it hourly through the
[jobs block](/docs/blocks/jobs):

```ts
handlers: {
  ai_cache_purge: aiCache.purgeJob({ batch: 5000 });
}
```

## Functions [#functions]

| Function                                             | Who     |
| ---------------------------------------------------- | ------- |
| `ai_cache_get(key)`                                  | service |
| `ai_cache_set(key, value, ttl, tenant, kind, model)` | service |
| `ai_cache_delete(key, tenant, model)`                | service |
| `purge_ai_cache(batch)`                              | service |

# AI chat

> Chats, projects and a branching message tree in a canonical message format, with runs, tool approvals, share links, a model catalog per plan and moderation events.

Source: https://bettersupabase.com/docs/blocks/ai-chat

The `ai-chat` block stores the conversations of an AI assistant: chats and
projects per user and tenant, a message tree with edits and regenerations
as branches, the run that writes each answer, tool approvals, share links,
the models each plan allows and moderation events. It doesn't call a model.
The [AI SDK adapter](/docs/ai-sdk) generates the answers and stores them
here; another SDK can do the same through the same methods.

```bash
better-supabase sql add ai-chat   # adds tenant, access and streams as well
```

| Table                  | Holds                                                                                                |
| ---------------------- | ---------------------------------------------------------------------------------------------------- |
| `ai_projects`          | Folders of chats with shared instructions, per owner and tenant                                      |
| `ai_chats`             | `title`, `model`, `visibility`, `pinned`, `archived_at`, `temporary`, the leaf and the active stream |
| `ai_messages`          | The tree: `parent_id`, `role`, `parts` in the message format below, `native`, `status`               |
| `ai_runs`              | One row per answer: model, status, token usage, the gateway's generation id and cost, the engine     |
| `ai_run_steps`         | The progress of a long run, one row per step key                                                     |
| `ai_harness_sessions`  | What a coding-agent harness keeps per chat: resume state, continue state and a lock (experimental)   |
| `ai_sandboxes`         | Every sandbox or container a chat or harness started: provider, id, status, idle seconds, last use   |
| `ai_tool_approvals`    | Approval requests for tool calls and the decision                                                    |
| `ai_tool_policies`     | `auto`, `ask` or `deny` per tool and tenant                                                          |
| `ai_pending_inputs`    | Questions a run asks the user, and the answer                                                        |
| `ai_message_sources`   | Cited sources per message                                                                            |
| `ai_message_feedback`  | Thumbs up or down per message and user                                                               |
| `ai_chat_shares`       | Share links, stored as the SHA-256 of the token                                                      |
| `ai_model_catalog`     | The models the app offers, with pricing and the plans that may use each                              |
| `ai_moderation_events` | Flags, redactions and blocks with their category and score                                           |

| Permission         | Lets a member                                        | Default roles              |
| ------------------ | ---------------------------------------------------- | -------------------------- |
| `ai_chat.read`     | read the tenant's chats shared with the organization | `owner`, `admin`, `member` |
| `ai_chat.create`   | start chats in the tenant                            | `owner`, `admin`, `member` |
| `ai_chat.share`    | share a chat with the organization or by link        | `owner`, `admin`, `member` |
| `ai_chat.moderate` | read the tenant's moderation events                  | `owner`, `admin`           |
| `ai_chat.admin`    | set tool policies and delete any chat in the tenant  | `owner`, `admin`           |

Private chats are readable by their owner only. Assistant messages, runs,
approvals and the model catalog are written by the service role, so a
client can't forge an answer.

## Server [#server]

```ts title="lib/ai-chat.ts"
import { createAiChat, rpcTransport } from "better-supabase/blocks/ai-chat";

export const aiChatFor = (supabase: SupabaseClient, admin: SupabaseClient) =>
  createAiChat({
    transport: rpcTransport(supabase),
    service: rpcTransport(admin),
  });
```

`transport` calls as the user and `service` as the service role. With
`better-supabase/postgres`, pass `sqlTransport(postgres.asUser(claims))` and
`sqlTransport(postgres.admin)`.

| Group        | Methods                                                                                     |
| ------------ | ------------------------------------------------------------------------------------------- |
| `chats`      | `create`, `get`, `list`, `update`, `remove`, `purge` (expired temporary chats)              |
| `projects`   | `create`, `update`, `list`, `remove`                                                        |
| `messages`   | `appendUser`, `saveAssistant`, `path`, `siblings`, `switchBranch`                           |
| `runs`       | `claim`, `release`, `stop`, `setCost`, `get`, `list`, `attach`, `steps`, `pendingApprovals` |
| `sandboxes`  | `register`, `touch`, `forChat`, `list`, `idle`, `finishStop`, `stopIdle`, `idleStopJob`     |
| `approvals`  | `request`, `decide`, `list`, `setPolicy`, `policies`                                        |
| `inputs`     | `open`, `answer`                                                                            |
| `feedback`   | `rate`                                                                                      |
| `shares`     | `create`, `revoke`, `list`, `get` (by token, for anyone who has it)                         |
| `models`     | `allowed` (what the tenant's plans allow), `upsert`                                         |
| `moderation` | `record`, `list`                                                                            |

Every method returns a `Result`. Database errors are `DbError` values, never
exceptions.

## Branches [#branches]

`appendUser` stores the user's message under the chat's leaf. Sending the
same id again is a no-op, and the same id with other parts is an edit,
stored as a sibling. With `trigger: "regenerate-message"` nothing is stored
and the leaf moves back, so the next answer becomes a sibling of the old
one. `path(chatId)` returns the active branch from the root, `siblings`
the alternatives at one message, and `switchBranch` shows another branch.

## Runs and streams [#runs-and-streams]

One answer runs at a time per chat. `runs.claim(chatId, streamId)` is a
compare-and-set on the chat: it fails while another stream is active,
unless that one is older than `staleAfter`. The writer appends to the
[durable stream](/docs/blocks/streams), so a reload resumes the answer, and
`runs.release` ends the run with its status and usage. `runs.stop` sets the
stream's cancel flag; the writer sees it on its next append.

A run is `queued`, `running`, `cancel_requested`, `completed`, `failed` or
`cancelled`. AI tasks and workflow runs use the same names, and
`FINAL_RUN_STATES` from `better-supabase/blocks` lists the three final
ones.

## Durable runs [#durable-runs]

A run's `engine` is `ai-sdk` for an answer written inside the request and
`workflow` for one that runs as a workflow
([durable chat](/docs/ai-sdk/workflow)), with the engine's own run id in
`external_run_id`. `runs` on the chat client reads them:

```ts
const { runs } = createAiChat({ transport, service: serviceTransport });
const mine = await runs.list({ active: true }).orThrow();
const steps = await runs.steps.list(mine[0].id).orThrow();
const inbox = await runs.pendingApprovals().orThrow();
```

| Method                       | Does                                                                  |
| ---------------------------- | --------------------------------------------------------------------- |
| `get`, `list`                | The caller's runs, or one chat's (`chatId`), newest first             |
| `attach`                     | Records the engine's run id (service role)                            |
| `steps.record`, `steps.list` | Records a step by its key (service role); the same key updates it     |
| `pendingApprovals`           | The caller's undecided approvals across chats, with each chat's title |

Members read the runs and steps of the chats they can read; only the
service role writes them.

### Harness sessions [#harness-sessions]

A coding-agent harness keeps its state per chat in `ai_harness_sessions`
through `createHarnessSessions`, on the service role. The block stores the
harness's resume and continue states as JSON without reading them. `lock`
keeps two turns from driving one sandbox for `ttlSeconds`, and `save` with a
`holder` fails with `AI_HARNESS_LOCKED` while another holder has the lock.
Members can't read the table, not even for their own chats, because resume
state can hold harness credentials. The store is experimental: its shape
follows the harnesses as they settle.

`save` with a `sandboxId` registers the session's sandbox in
`ai_sandboxes` (provider `harness` unless `sandboxProvider` names one), and
`sandboxId: null` marks it stopped. `load` returns the running sandbox's id,
so a harness reads and writes `sandboxId` as before. The
[idle-stop job](#sandboxes) stops harness sandboxes with the rest, skips one
whose session a turn holds the lock of, and marks the session `stopped` once
its last sandbox stopped.

## Sandboxes [#sandboxes]

`ai_sandboxes` holds every sandbox or provider container a chat or a harness
session started, so one idle-stop job stops all of them. Register each
sandbox the app starts, and mark it used while it runs:

```ts
const { sandboxes } = createAiChat({ transport, service: serviceTransport });
const row = await sandboxes
  .register(organizationId, {
    provider: "vercel",
    sandboxId: sandbox.id,
    chatId,
  })
  .orThrow();
await sandboxes.touch(row.id);
```

`forChat(chatId, provider)` returns the chat's running sandbox so the next
message reuses it. A sandbox started in another tenant is not reused:
`register` fails with the hint `AI_SANDBOX_FORBIDDEN`. Schedule the idle-stop
job every few minutes; it claims sandboxes that nobody used for their
`idleSeconds`, or that passed `expiresAt`, and calls your stopper:

```ts title="jobs.ts"
import { Sandbox } from "@vercel/sandbox";

export const handlers = {
  ai_sandbox_idle: sandboxes.idleStopJob((sandbox) =>
    Sandbox.get({ sandboxId: sandbox.sandboxId }).then((s) => s.stop()),
  ),
};
```

A harness sandbox reaches the stopper with its `harnessId` set. A stopper
that throws leaves the sandbox running with the error on the row, and the
next run tries again.

| Option             | Default | Sets                                                         |
| ------------------ | ------- | ------------------------------------------------------------ |
| `sandboxIdleAfter` | 600     | Seconds without use before the idle-stop job stops a sandbox |

## Share links [#share-links]

`shares.create(chatId)` returns a token once and stores only its hash. The
link shows the branch it was made from, frozen at that message.
`shares.get(token)` reads it without signing in; `revoke` ends it.

## Client [#client]

```tsx title="app/chats/sidebar.tsx"
"use client";

import { useAiChats } from "better-supabase/blocks/ai-chat/react";

export function Sidebar({ organizationId }: { organizationId: string }) {
  const { items, hasMore, loadMore, create } = useAiChats({ organizationId });
  // ...
}
```

| Hook                          | Returns                                                                       |
| ----------------------------- | ----------------------------------------------------------------------------- |
| `useAiChats(query)`           | The user's chats, newest first, with `loadMore`, `create`, `update`, `remove` |
| `useAiChatTree(chatId)`       | The active branch, with `switchBranch` and `step` between siblings            |
| `useAiModels(organizationId)` | The models the tenant may use, for a model picker                             |
| `useAiShare(chatId)`          | The chat's share links, with `create` and `revoke`                            |

The list and the tree load again when the private Realtime topics
`ai-chats:{userId}` and `ai-chat:{chatId}` announce a change
(`sql.modules["ai-chat"].options.listTopic` and `topic`). In Server
Components the hooks throw; call the block on the server instead.

## Message format [#message-format]

`better-supabase/blocks/ai-chat` defines the message format the AI blocks
store. A message has an `id`, a `role` (`system`, `user`, `assistant` or
`tool`), a list of `parts` and optional `metadata`. The format belongs to
better-supabase, not to one SDK: an adapter for the AI SDK or another SDK
converts its own messages to these parts and back, so the stored history,
search and memory work the same whichever SDK wrote them.

```ts
import {
  aiMessageSchema,
  type AiMessage,
} from "better-supabase/blocks/ai-chat";

const message: AiMessage = {
  id: "msg_1",
  role: "assistant",
  parts: [
    { type: "text", text: "Here is the invoice." },
    {
      type: "tool-call",
      toolCallId: "call_1",
      toolName: "getInvoice",
      input: { id: 42 },
    },
  ],
};

const result = await aiMessageSchema["~standard"].validate(message);
```

`aiMessageSchema` is a [Standard Schema](https://standardschema.dev), so it
works anywhere a Zod or Valibot schema does. `isAiMessage(value)` is the
type guard. An adapter may also keep its own copy of a message in `native`
(the AI SDK adapter stores the `UIMessage`), and reads it back with
`path(chatId, { native: true })`.

### Parts [#parts]

| Type            | Fields                                                                              |
| --------------- | ----------------------------------------------------------------------------------- |
| `text`          | `text`, `state` (`streaming` or `done`)                                             |
| `reasoning`     | `text`, `state`                                                                     |
| `file`          | `mediaType`, `url`, `filename`                                                      |
| `tool-call`     | `toolCallId`, `toolName`, `input`, `providerExecuted`                               |
| `tool-result`   | `toolCallId`, `toolName`, `output`, `isError`                                       |
| `tool-approval` | `toolCallId`, `approvalId`, `state` (`requested`, `approved` or `denied`), `reason` |
| `source`        | `sourceType` (`url` or `document`), `id`, `url`, `title`, `mediaType`               |
| `data`          | `name`, `id`, `data`                                                                |
| `step`          | `boundary` (`start` or `finish`), `stepId`                                          |

Every part can carry `providerMetadata`, an object the adapter passes
through unchanged. Unknown fields are rejected. An SDK part with no
counterpart becomes a `data` part, so a conversion never drops content.

### JSON Schema [#json-schema]

The same format is published as JSON Schema 2020-12 at
`https://unpkg.com/better-supabase/schemas/ai-message-v1.json`
(`AI_MESSAGE_SCHEMA_ID`). Use it to validate messages outside TypeScript or
in a `jsonb` check constraint. The version is pinned as
`SPEC_PINS.aiMessage`; a change to the format ships as a new schema file and
a new pin, never as an edit to version 1. See [Standards](/docs/standards).

# AI files

> Attachments and generated files for AI chats in a private bucket, provider file references with expiry, and versioned documents with suggested edits.

Source: https://bettersupabase.com/docs/blocks/ai-files

The `ai-files` block stores the files of an AI assistant: what a user
attaches to a message, what a model generates (images, speech), the
reference a provider's file API returned for a file, and documents the
assistant writes beside the chat, with every version kept. Messages point
at a file with a `supabase-storage://` URL, so a stored chat never holds a
signed URL that expires or a file someone else owns.

```bash
better-supabase sql add ai-files   # adds tenant and access as well
```

| Table                  | Holds                                                                                            |
| ---------------------- | ------------------------------------------------------------------------------------------------ |
| `ai_files`             | One row per object: owner, tenant, chat, `media_type`, `byte_size`, `sha256`, `status`, `source` |
| `ai_provider_files`    | A provider's file id or file URI per file, with `expires_at`                                     |
| `ai_documents`         | Documents per owner and chat: `kind` (`text`, `code`, `sheet`, `image`), `title`, the version    |
| `ai_document_versions` | Every version: `content` or a `storage_path`, the message that wrote it                          |
| `ai_suggestions`       | Suggested edits on a version, and whether the owner accepted them                                |

Objects live in the private `ai-files` bucket at
`{organization_id}/{owner_id}/{file_id}/{filename}`. The storage policies
accept an upload only to the path of a pending record the caller reserved,
and allow a read to the owner, an `ai.admin` of the tenant and, with
the [`ai-chat`](/docs/blocks/ai-chat) module installed, anyone who can read
the file's chat.

| Permission  | Lets a member                                         | Default roles              |
| ----------- | ----------------------------------------------------- | -------------------------- |
| `ai.create` | upload files and create documents in the tenant       | `owner`, `admin`, `member` |
| `ai.admin`  | read and delete every file and document in the tenant | `owner`, `admin`           |

The keys are the `ai-chat` keys by default; rename them with
`sql.modules["ai-files"].permissions.upload` and `.manage`.

| Option             | Default    | Sets                                                            |
| ------------------ | ---------- | --------------------------------------------------------------- |
| `bucket`           | `ai-files` | The bucket id                                                   |
| `maxSize`          | 50 MB      | The largest file in bytes, in the table and the bucket          |
| `allowedMimeTypes` | any        | Media types the bucket and `reserve_ai_file` accept (`image/*`) |
| `pendingTtl`       | `1 day`    | How long `purge` keeps an upload that never finished            |

## Server [#server]

```ts title="lib/ai-files.ts"
import { createAiFiles, rpcTransport } from "better-supabase/blocks/ai-files";

export const aiFilesFor = (supabase: SupabaseClient, admin: SupabaseClient) =>
  createAiFiles({
    transport: rpcTransport(supabase),
    storage: supabase.storage,
    service: rpcTransport(admin),
    serviceStorage: admin.storage,
  });
```

`transport` and `storage` act as the user, so the table and bucket
policies apply. `service` and `serviceStorage` act as the service role for
what only the server writes: generated files, provider references and the
purge.

| Group           | Methods                                                                                          |
| --------------- | ------------------------------------------------------------------------------------------------ |
| `files`         | `upload`, `confirm`, `get`, `resolve`, `list`, `sign`, `read`, `remove`, `store`, `purge`        |
| `providerFiles` | `get`, `set`, `expiring`                                                                         |
| `documents`     | `create`, `get`, `update`, `rollback`, `versions`, `remove`, `suggest`, `suggestions`, `resolve` |

Every method returns a `Result`. A file the caller can't see is
`not_found`, the same as a file that doesn't exist.

## Uploads [#uploads]

```ts
const { file, token } = await files.files
  .upload(organizationId, {
    filename: "plan.pdf",
    mediaType: "application/pdf",
    size: blob.size,
    chatId,
  })
  .orThrow();
await supabase.storage
  .from(file.bucket)
  .uploadToSignedUrl(file.path, token, blob);
await files.files.confirm(file.id).orThrow();
const part = { type: "file", mediaType: file.mediaType, url: aiFileUrl(file) };
```

`upload` reserves the record and returns a signed upload URL for its path.
For files over 6 MB, upload to the same path with TUS
(`/storage/v1/upload/resumable`) instead; the policies are the same.
`confirm` checks that the object exists and takes its stored size. Run
`purge` from a job: it deletes pending uploads older than `pendingTtl`,
expired files and the files of deleted chats, then removes their objects.

## Reading files [#reading-files]

`resolve(url)` looks a `supabase-storage://` URL up as the caller and fails
for a file they can't read, so a user can't send a model another user's
file by pasting its URL. `read` returns the bytes and refuses files over
`maxBytes` (50 MB by default). `sign(messages)` replaces the storage URLs of
file parts with signed URLs that expire after `downloadTtl` seconds (300 by
default), for a browser or a provider that fetches URLs itself.

## Documents [#documents]

```ts
const doc = await files.documents
  .create(organizationId, { kind: "code", title: "main.ts", content, chatId })
  .orThrow();
await files.documents
  .update(doc.id, { content: next, expectedVersion: doc.version })
  .orThrow();
```

`update` writes a new version; with `expectedVersion` it fails with
`conflict` (`AI_DOCUMENT_CONFLICT`) when another writer got there first.
`rollback(id, version)` writes an old version as the newest one, so the
history stays linear. `suggest` records an edit on the current version and
`resolve` accepts or rejects it; accepting doesn't change the document, the
client writes the new version.

## AI SDK [#ai-sdk]

[`better-supabase/ai-sdk/files`](/docs/ai-sdk/files) reads these files in
`experimental_download`, stores generated files and caches provider file
references.

# AI providers

> Each tenant's own provider keys as credential references, and a registry of provider batch jobs with a poll schedule.

Source: https://bettersupabase.com/docs/blocks/ai-providers

The `ai-providers` block holds what an app tracks about the model providers
it calls on a tenant's behalf: the tenant's own API keys (bring your own
key) and batch jobs that run at the provider for hours. Code sandboxes live
in the [ai-chat](/docs/blocks/ai-chat#sandboxes) block. It stores
no secret and imports no SDK; the
[AI SDK helpers](/docs/ai-sdk/providers) and [batches](/docs/ai-sdk/batches)
connect it to the AI SDK.

```bash
better-supabase sql add ai-providers   # adds tenant and access as well
```

| Table              | Holds                                                                               |
| ------------------ | ----------------------------------------------------------------------------------- |
| `ai_provider_keys` | One key per tenant, provider and name: a `credential_ref`, `settings` and `enabled` |
| `ai_batches`       | A provider batch: its reference, status, counts, expiry and when to poll it next    |
| `ai_batch_items`   | One result per request of a finished batch: status, output, usage and error         |

Organization exports leave out `credential_ref`, and the organization
purge in [data lifecycle](/docs/blocks/data-lifecycle#credentials) revokes each key's credential before it deletes the row.

| Permission  | Lets a member                                        | Default roles              |
| ----------- | ---------------------------------------------------- | -------------------------- |
| `ai.create` | start batches, and read their own batches            | `owner`, `admin`, `member` |
| `ai.admin`  | list, save and remove the keys, and read every batch | `owner`, `admin`           |

| Option      | Default | Sets                                                                 |
| ----------- | ------- | -------------------------------------------------------------------- |
| `pollEvery` | 60      | Seconds between two polls of a batch; the first poll waits this long |

## Creating the client [#creating-the-client]

```ts title="lib/ai-providers.ts"
import "server-only";

import {
  createAiProviders,
  rpcTransport,
} from "better-supabase/blocks/ai-providers";
import { vaultCredentials } from "better-supabase/credentials";

const service = rpcTransport(bs.admin().$client, { schema: "api" });

export const providersFor = (supabase: SupabaseClient) =>
  createAiProviders({
    transport: rpcTransport(supabase, { schema: "api" }),
    service,
    credentials: vaultCredentials({ transport: service, schema: "api" }),
    schema: "api",
  });
```

## Keys [#keys]

A key row names a credential, never the key itself. Store the key with the
[credential provider](/docs/extending/credentials) first, then save the
reference:

```ts
import { tenantCredentialRef } from "better-supabase/credentials";

const ref = tenantCredentialRef(organizationId, {
  provider: "vault",
  secret: "anthropic",
});
await credentials.set(ref, apiKey).orThrow();
await providers.keys
  .save(organizationId, { provider: "anthropic", credentialRef: ref })
  .orThrow();
```

The ref must carry the key's tenant
([tenant refs](/docs/extending/credentials#tenant-refs)); any other ref fails
with `CREDENTIAL_REF_FOREIGN`. `save` replaces the key with the same provider and name, and revokes the
credential the old row named. `remove(keyId)` revokes the credential and
deletes the row. `resolve(organizationId)` reads the enabled keys with their
tokens for one request, through the service role; pass the result to
[`byokOptions`](/docs/ai-sdk/providers). Members never see a token: `list`
returns the rows without one.

When a tenant is deleted, run `keys.removeAll(organizationId)` before the
tenant's rows go, so every credential is revoked at the provider.

## Batches [#batches]

`batches.record` stores a batch the app started, with the SDK's reference.
The poll job claims due batches with `due({ batch, leaseSeconds })`, so two
workers never poll the same one, and writes the outcome with `update` and
`saveItems`. `items(batchId, { cursor, limit })` pages the results by request
id. [`aiBatches`](/docs/ai-sdk/batches) does all of this for AI SDK batches.

# AI tasks

> Prompts a user schedules on a cron in their time zone, a scheduler that queues each run, and a run log with the chat each run wrote to.

Source: https://bettersupabase.com/docs/blocks/ai-tasks

The `ai-tasks` block runs a prompt on a schedule, such as a summary of new
tickets every weekday at 9:00 in the user's time zone. Each run is logged
with its status and the chat it wrote to.

```bash
better-supabase sql add ai-tasks   # adds tenant and access as well
```

| Table                | Holds                                                              |
| -------------------- | ------------------------------------------------------------------ |
| `ai_scheduled_tasks` | The prompt, cron, time zone, optional chat and agent, and next run |
| `ai_task_runs`       | One row per run: `queued`, `running`, `completed` or `failed`      |

A user sees and changes their own tasks; `ai.admin` lists every task
in the organization. With [agents](/docs/blocks/agents) installed, a task
can name the agent it runs as.

| Permission  | Lets a member                      | Default roles              |
| ----------- | ---------------------------------- | -------------------------- |
| `ai.create` | schedule tasks                     | `owner`, `admin`, `member` |
| `ai.admin`  | see every task in the organization | `owner`, `admin`           |

| Option       | Default       | Sets                                               |
| ------------ | ------------- | -------------------------------------------------- |
| `maxPrompt`  | 20,000        | The longest prompt, in characters                  |
| `queue`      | `ai_task_run` | The jobs queue a run is sent to                    |
| `staleAfter` | `30 minutes`  | When a `running` run counts as abandoned and fails |

## Server [#server]

```ts title="lib/ai-tasks.ts"
import { createAiTasks, rpcTransport } from "better-supabase/blocks/ai-tasks";

export const tasksFor = (supabase: SupabaseClient, admin: SupabaseClient) =>
  createAiTasks({
    transport: rpcTransport(supabase),
    service: rpcTransport(admin),
    run: async (task, run, signal) => {
      const chatId = await runAssistant(task, signal);
      return { chatId };
    },
  });
```

```ts
await tasks
  .create(organizationId, {
    title: "Ticket summary",
    prompt: "Summarize the tickets opened since yesterday.",
    cron: "0 9 * * 1-5",
    timezone: "Europe/Amsterdam",
  })
  .orThrow();
```

The block computes the next occurrence on the server from the cron and
time zone; a bad cron or zone fails with `invalid_input`. Change `cron`
and `timezone` together. `pause` and `resume` turn a task off and on, and
`runs(taskId)` lists its recent runs.

Only the service role sets `next_run_at`. A member's create, or a change to
`cron` or `timezone`, clears it, and the next `tick()` schedules the task;
with a `service` transport the block schedules it right after the save. A
`chatId` must name a chat the task's user owns in the same tenant
(`AI_TASK_CHAT_FORBIDDEN`), and an `agentId` an agent in the tenant that
the user owns or that is published (`AI_TASK_AGENT_FORBIDDEN`). An unknown
time zone fails with `AI_TASK_INVALID`.

## Running tasks [#running-tasks]

AI tasks are the per-user layer of scheduling; app-level job schedules and
workflow schedules are compared in
[three scheduling layers](/docs/blocks/jobs#three-scheduling-layers).

`tick()` claims the due tasks as the service role, queues a run for each
and sets the next occurrence. With [jobs](/docs/blocks/jobs) installed, each
run is sent to the `ai_task_run` queue and `runJob()` is its handler;
without jobs, call `drain()` from a cron route to tick and run in one go.
`drain()` runs every claimed task even when one fails, then returns the
first error with a `details` line that lists each failed run. A run that
stays `queued` longer than the stale timeout is marked `failed` on the
next `tick()`, so a lost queue message doesn't block its task.

`execute(runId)` marks the run `running`, calls your `run` function and
marks it `completed` or `failed` with the error. It returns `false` when
another worker already has the run. The optional `notify` callback gets
every outcome, for a notification or an email.

# Announcements

> In-app announcements for everyone, some tenants, some roles or some plans within a time window, with dismissals and a useAnnouncements hook over Realtime.

Source: https://bettersupabase.com/docs/blocks/announcements

The `announcements` block shows banners such as "Scheduled maintenance
tonight" or "New: exports". Staff publish an announcement with an audience
and a time window; each user sees the live ones they haven't dismissed, and
open clients load the list again when one changes.

```bash
better-supabase sql add announcements   # adds tenant and access as well
```

| Table                     | Holds                                                                                                    |
| ------------------------- | -------------------------------------------------------------------------------------------------------- |
| `announcements`           | `title`, `body`, `severity`, `href`, `audience` and its `targets`, `starts_at`, `ends_at`, `dismissible` |
| `announcement_dismissals` | Which user dismissed which announcement                                                                  |

| Permission             | Lets                                      | Default roles          |
| ---------------------- | ----------------------------------------- | ---------------------- |
| `announcements.manage` | platform staff publish, change and remove | platform roles with it |

## Audiences [#audiences]

| Audience                                     | Who sees it                                       |
| -------------------------------------------- | ------------------------------------------------- |
| `{ type: "all" }`                            | Every signed-in user                              |
| `{ type: "tenant", organizationIds: [...] }` | Members of those organizations                    |
| `{ type: "role", roles: ["admin"] }`         | Members with one of those roles                   |
| `{ type: "plan", plans: ["pro"] }`           | Members of tenants with one of those entitlements |

Tenant, role and plan audiences match against the tenant the client passes,
usually the active one; a tenant the user isn't a member of counts as none.
The plan audience needs the [entitlements](/docs/blocks/entitlements) module.

## Publishing [#publishing]

```ts title="app/admin/announcements/actions.ts"
import {
  createAnnouncements,
  sqlTransport,
} from "better-supabase/blocks/announcements";

const announcements = createAnnouncements({
  transport: sqlTransport(postgres.admin),
});

const notice = await announcements
  .publish({
    title: "Scheduled maintenance",
    body: "The app is read-only from 22:00 to 22:30 UTC.",
    severity: "warning",
    audience: { type: "all" },
    startsAt: Temporal.Instant.from("2026-11-02T20:00:00Z"),
    endsAt: Temporal.Instant.from("2026-11-02T22:30:00Z"),
    dismissible: false,
  })
  .orThrow();

await announcements.update(notice.id, { body: "Moved to 23:00 UTC." });
await announcements.list();
await announcements.remove(notice.id);
```

`severity` is `info` (the default), `success`, `warning` or `critical`.
`href` must start with `https://` or `/`. The body is text you render; the
block doesn't interpret it.

## Showing them [#showing-them]

```ts
const live = await announcements.listActive(organizationId).orThrow();
await announcements.dismiss(live[0].id);
```

`dismiss` raises `ANNOUNCEMENT_NOT_DISMISSIBLE` for an announcement with
`dismissible: false`.

In Client Components, `useAnnouncements` loads the list and subscribes to
the private `announcements` topic. The module broadcasts there whenever an
announcement is published, changed or removed, and the hook loads the list
again:

```tsx title="components/announcement-bar.tsx"
"use client";

import { useAnnouncements } from "better-supabase/blocks/announcements/react";

export function AnnouncementBar({
  organizationId,
}: {
  organizationId: string;
}) {
  const { items, dismiss } = useAnnouncements({ organizationId });
  return items?.map((item) => (
    <div key={item.id} role="status" data-severity={item.severity}>
      <strong>{item.title}</strong> {item.body}
      {item.dismissible && (
        <button onClick={() => dismiss(item.id)}>Dismiss</button>
      )}
    </div>
  ));
}
```

Change the topic and the broadcast event with
`sql.modules.announcements.options.topic` and `event`, and pass the same
`topic` to the hook (`null` loads once without Realtime).

## Functions [#functions]

| Function                        | Granted to                      | Does                                          |
| ------------------------------- | ------------------------------- | --------------------------------------------- |
| `active_announcements(tenant)`  | `authenticated`, `service_role` | The caller's live, undismissed announcements  |
| `dismiss_announcement(id)`      | `authenticated`, `service_role` | Hides one for the caller                      |
| `list_announcements()`          | `authenticated`, `service_role` | Every announcement, for staff                 |
| `save_announcement(id, fields)` | `authenticated`, `service_role` | Creates (`id` null) or changes one, for staff |
| `delete_announcement(id)`       | `authenticated`, `service_role` | Removes one, for staff                        |

# API keys

> Hashed API keys for tenants and users, with scopes, expiry, rotation, a rate limit per key and an apiKey caller in every server adapter.

Source: https://bettersupabase.com/docs/blocks/api-keys

The `api-keys` block issues keys your customers call your API with. A key
looks like `bs_0f3a9c2e7b1d4a68_<secret><checksum>`: a prefix, a public id, a
43-character base62 secret and the 6-character CRC-32 checksum of everything
before it. Only the SHA-256 of the secret is stored, so a database dump holds no
usable key, and the token is shown once when the key is created. The checksum
lets `verify` and secret scanners reject a mistyped or made-up key without a
database lookup; keys created before it (no checksum) still verify.

Set the prefix to your product's, such as `acme`, with `createApiKeys({ prefix })`
and `sql.modules.api-keys.options.prefix`, so scanners and support can tell your
keys apart. It is `bs` by default.

```bash
better-supabase sql add api-keys   # adds tenant and access as well
```

A key acts in one of three ways:

| Key                              | Created with                          | Acts as                                      |
| -------------------------------- | ------------------------------------- | -------------------------------------------- |
| Tenant key                       | `organizationId`                      | The tenant: `api_key_tenant()` is its id     |
| Personal key                     | `personal: true`                      | The user who created it                      |
| Personal key limited to a tenant | `personal: true` and `organizationId` | The user, only while they are still a member |

Creating a tenant key needs `api_keys.manage` in the tenant; a personal key
in a tenant needs `api_keys.own`. A personal key without a tenant acts in
every tenant its user belongs to, so it needs `api_keys.own` in each of them;
a user without any membership can still create one. The default roles give
`admin` both and `member` the second. API keys are never part of a
[data export](/docs/blocks/data-lifecycle); the purge still deletes a
tenant's keys. Rename the keys with `sql.modules.api-keys.permissions`.

## Managing keys [#managing-keys]

```ts title="app/settings/api-keys/actions.ts"
import { createApiKeys, rpcTransport } from "better-supabase/blocks/api-keys";

const keys = createApiKeys({ transport: rpcTransport(supabase) });

const { key, token } = await keys
  .create({
    name: "CI",
    organizationId,
    scopes: ["deals:read"],
    expiresAt: Temporal.Now.instant().add({ hours: 24 * 90 }),
    rateLimit: 600, // requests per minute
  })
  .orThrow();
// Show `token` once; `key` has everything else.

await keys.list(organizationId); // the tenant's keys for managers, the caller's own otherwise
await keys.rotate(key.id, { grace: Temporal.Duration.from({ hours: 1 }) });
await keys.revoke(key.id);
```

`rotate` returns a new token and keeps the old one working for the grace
period (one day by default), so a deployment can switch over. `revoke` stops
the key at once.

Every key that `create`, `list` and `rotate` return has a `state`, computed
with the database clock, so a list shows a rotated key as still working
while its grace period runs:

| `state`   | The key                                         |
| --------- | ----------------------------------------------- |
| `active`  | authenticates                                   |
| `grace`   | was rotated and authenticates until `revokedAt` |
| `revoked` | was revoked, or its grace period ended          |
| `expired` | passed its `expiresAt`                          |

A rotated key also has `successorId`, the id of the key that replaced it.

## Accepting keys [#accepting-keys]

`apiKeyResolver` reads the `x-api-key` header, or a Bearer token shaped like
a key, and turns it into an `apiKey` caller. Requests without a key fall
through to the usual JWT and cookie resolution. Verification runs as the
service role, so give the resolver a service transport:

```ts title="src/server.ts"
import { createPostgres } from "better-supabase/postgres";
import {
  apiKeyResolver,
  createApiKeys,
  sqlTransport,
} from "better-supabase/blocks/api-keys";

const postgres = createPostgres({ connectionString: env.SUPABASE_DB_URL });
const keys = createApiKeys({ transport: sqlTransport(postgres.admin) });

export const bs = createHono(betterSupabase, {
  postgres,
  auth: { resolvers: [apiKeyResolver({ keys })] },
});

app.use(
  "/api/deals/*",
  bs.middleware({ allow: ["user", "apiKey"], scopes: ["deals:read"] }),
);
```

The guard checks `scopes` against the key's scopes; `*` grants every scope.
An invalid, expired or revoked key answers 401, and a key over its rate limit
answers 429 with `Retry-After`. A personal key also answers 401 while its user
is disabled, banned in Supabase Auth (`auth.users.banned_until` in the future)
or soft-deleted, the same as the user's sign-in. Each verification counts against the key's
limit and updates `last_used_at` at most once a minute
(`sql.modules.api-keys.options.touchInterval`, in seconds).

The `apiKey` caller carries `createdAt` (a `Temporal.Instant`) and
`createdBy` (the creating user's id) when the key row has them, so a guard or
an authorization check can tell who issued the key. `toSession` returns
`createdAt` in epoch seconds, like the session's other times.

There is no JWT for an API key, so PostgREST can't run its queries. `ctx.db`
and `ctx.sql` run over direct Postgres instead (pass `postgres` to the
server), with these claims and the key's tenant as `better_supabase.tenant`:

```json
{
  "sub": "<user id, or empty for a tenant key>",
  "role": "authenticated",
  "api_key": {
    "id": "…",
    "name": "CI",
    "organization_id": "…",
    "scopes": ["deals:read"]
  }
}
```

`ctx.supabase` and `ctx.db.$client` throw for an API key caller.
`authMode` (for `withSupabase` entries) is `user` for a personal key and
`secret` for a tenant key.

### On the `@supabase/server` pipeline [#on-the-supabaseserver-pipeline]

A REST API built on `@supabase/server`'s `pipeline` takes keys with
`withApiKey` in place of `withSupabase`. It verifies the key, answers a
missing, invalid or rate-limited key with Problem Details (`problem` sets
your error format), and contributes `ctx.auth` and the `withSupabase` keys
(`jwtClaims`, `userClaims`, `authMode`) from the key's claims, so
`withPostgresClient` runs each query as the key:

```ts title="api/deals.ts"
import { pipeline } from "@supabase/middleware";
import { withPostgresClient } from "@supabase/server/middleware/postgres";
import {
  createApiKeys,
  sqlTransport,
  withApiKey,
} from "better-supabase/blocks/api-keys";
import { withBetterPostgres } from "better-supabase/server";

export default {
  fetch: pipeline(
    [
      withApiKey({ keys }),
      withPostgresClient(),
      withBetterPostgres(betterSupabase)(),
    ],
    async (_request, ctx) =>
      Response.json(await ctx.sql.deals.findMany().orThrow()),
  ),
};
```

## In RLS [#in-rls]

A personal key runs as its user, so existing policies apply. Add
`has_scope()` where a policy should also respect the key's scopes; it is
`true` for requests without a key. Wrap both functions in `(select ...)`, as
below, so Postgres calls them once per statement instead of once per row. A
tenant key has no user, so give it its own policy:

```sql
create policy deals_read_with_key on public.deals for select to authenticated
  using (
    organization_id = (select better_supabase.api_key_tenant())
    and (select better_supabase.has_scope('deals:read'))
  );
```

## SQL [#sql]

| Function                                      | Grants                          | Returns                                                   |
| --------------------------------------------- | ------------------------------- | --------------------------------------------------------- |
| `create_api_key(name, public_id, hash, ...)`  | `authenticated`, `service_role` | The key, without its hash                                 |
| `list_api_keys(tenant)`                       | `authenticated`, `service_role` | Keys the caller may see                                   |
| `revoke_api_key(key)`                         | `authenticated`, `service_role` | `true`                                                    |
| `rotate_api_key(key, public_id, hash, grace)` | `authenticated`, `service_role` | The new key                                               |
| `verify_api_key(public_id, hash)`             | `service_role`                  | `{ status: 'ok', key }`, `invalid` or `rate_limited`      |
| `has_scope(scope)`                            | everyone                        | Whether the request's key carries `scope` (or has no key) |
| `api_key_tenant()`                            | everyone                        | The tenant of the request's key, or `null`                |

Restrict the scopes keys may carry with `sql.modules.api-keys.options.scopes`
(a list); `create_api_key` then refuses any other with `API_KEY_SCOPE_UNKNOWN`.
With an [authorization provider](/docs/extending/authorization-providers),
`scopes: "catalog"` takes the list from the provider's `permissions`, so a
key's scopes are the same permission keys your guards and policies check, and
`sql add` fails when the provider lists none.

## With an authorization provider [#with-an-authorization-provider]

Queries for a key run with the `api_key` claim. A provider's SQL functions
can read a claim too, to make the key's scopes a ceiling or to treat a tenant
key as a service principal of its tenant. Pass `claim` to the resolver (or to
`apiKeyClaims`) so the request also carries the fields the provider reads:

```ts
apiKeyResolver({
  keys,
  claim: {
    name: "authz_key",
    tenant: "organization_id",
    serviceRoles: ["integration"],
  },
  allPermissions: permissionKeys,
  tenantClaim: "tenant_id",
});
```

| Field          | Default  | Meaning                                                                        |
| -------------- | -------- | ------------------------------------------------------------------------------ |
| `name`         | required | the claim; `api_key` adds the fields to the module's own claim                 |
| `scopes`       | `scopes` | the field with the key's permission keys                                       |
| `tenant`       | `tenant` | the field with a tenant key's tenant                                           |
| `roles`        | `roles`  | the field with a tenant key's roles                                            |
| `serviceRoles` | none     | the roles a tenant key holds, unless the `serviceRoles` option answers per key |

A personal key limited to a tenant also gets `tenantClaim`, the claim that
narrows the provider's functions to one tenant. The claim has no wildcard:
`*` expands to `allPermissions`, and without it a key with `*` allows nothing
there or in `has_scope()`. Under the provider access model `create_api_key`
refuses `*` (`API_KEY_SCOPE_WILDCARD`) unless `options.scopes` lists it, and
`options.scopes: "catalog"` keeps every scope a permission key the provider
lists.

A provider's server package can verify the block's keys for its own
middleware: `keys.verify(token)` resolves to `{ status: "ok", key }` with
the key's user or tenant and its scopes, `invalid` or `rate_limited`.

# Attachments

> Files linked to records in a tenant, uploaded through signed URLs to a private bucket and served only after a malware scan.

Source: https://bettersupabase.com/docs/blocks/attachments

The `attachments` block links files to records in a tenant. Each file gets
a row in `attachments` and an object in a private Storage bucket at
`{organization_id}/attachments/{id}`. The bucket's policies accept an
upload only when a pending record of the caller expects it, and serve a
file only after your scanner marks it clean.

```bash
better-supabase sql add attachments   # adds tenant and access as well
```

| Column                       | Holds                                                               |
| ---------------------------- | ------------------------------------------------------------------- |
| `subject_type`, `subject_id` | The record the file belongs to, or both null                        |
| `bucket`, `object_path`      | Where the object lives; the path is generated from the id           |
| `name`, `mime_type`, `size`  | The file name, and the type and size Storage stored                 |
| `status`, `scan_detail`      | `pending`, `clean`, `infected` or `failed`, and what the scan found |
| `uploaded_by`, `uploaded_at` | The uploader, and when `confirm` saw the object                     |

| Permission           | Lets a member                                 | Default roles             |
| -------------------- | --------------------------------------------- | ------------------------- |
| `attachments.read`   | list attachments and download clean files     | `member`, `admin`         |
| `attachments.upload` | upload files                                  | `member`, `admin`         |
| `attachments.manage` | read any file and delete other members' files | `admin` (`attachments.*`) |

The uploader can always read and delete their own file.

## Options [#options]

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      attachments: {
        options: {
          bucket: "attachments",
          maxSize: 25 * 1024 * 1024,
          allowedMimeTypes: ["image/*", "application/pdf"],
          requireScan: true,
        },
      },
    },
  },
});
```

The module creates the bucket as private with `maxSize` (default 50 MiB) as
its file size limit and `allowedMimeTypes` as its allowed types; an existing
bucket keeps its settings. The table checks the same limits. With
`requireScan: false`, `confirm` marks a file clean right away, and files
nobody scanned are served too (an `infected` one never is).

### Subjects and buckets [#subjects-and-buckets]

`subjects` maps each subject type to its table, as for
[comments](/docs/blocks/comments#subjects): uploading and reading a file then
also need the subject row to be readable through the subject table's own
policies (and its `permission`), and an unlisted type is refused. A subject
can keep its files in its own bucket with its own MIME types, so an app with a
bucket per feature keeps them:

```ts
options: {
  bucket: "attachments",
  path: "{organization_id}/{subject_type}/{subject_id}/{id}",
  subjects: {
    chat_message: { table: "chat_messages", bucket: "chat", allowedMimeTypes: ["image/*", "application/pdf"] },
    expense: { table: "expenses", bucket: "receipts", allowedMimeTypes: ["image/*"], cascade: true },
  },
},
```

The module creates every listed bucket that doesn't exist yet and adds its
storage policies for each, next to any policies the bucket already has. With
subjects, reading an object also needs its subject: the storage read policy
checks that the caller sees the attachment row through its own read policy
(`attachment_object_visible`), so a member who can't see the subject, or who
isn't in the tenant, can't download its files, uploader and
`attachments.manage` included.
`cascade: true` deletes a subject's attachment rows with the subject row;
remove the objects with `attachments.remove` or a storage cleanup job.

A subject without a tenant, such as a user's own notes, takes `tenant: false`:
its files have no `organization_id` (pass `organizationId: null` to `upload`),
their path starts with `-`, and the subject table's own policies alone decide
who reads and uploads them, without tenant permissions. Other files still need
a tenant.

```ts
subjects: {
  note: { table: "notes", tenant: false },
},
```

`path` sets the object path, from `{organization_id}` (required first),
`{id}` (required), `{subject_type}` and `{subject_id}` (a file without a
subject gets `-`). It defaults to `{organization_id}/attachments/{id}`.
Changing it later rewrites the column on Postgres 17; on older versions,
recreate the column in a migration.

## Upload and download [#upload-and-download]

Uploading takes three steps: create the record and a signed upload URL on
the server, upload the file from the browser, then confirm.

```ts title="app/projects/[id]/actions.ts"
"use server";

import {
  createAttachments,
  rpcTransport,
} from "better-supabase/blocks/attachments";

export async function startUpload(projectId: string, file: FileMeta) {
  const supabase = await createServerClient();
  const attachments = createAttachments({
    transport: rpcTransport(supabase),
    storage: supabase.storage,
  });
  return attachments
    .upload({
      organizationId,
      subjectType: "project",
      subjectId: projectId,
      name: file.name,
      mimeType: file.type,
      size: file.size,
    })
    .orThrow();
}
```

The browser uploads with the token,
`supabase.storage.from(bucket).uploadToSignedUrl(path, token, file)`, or a
`PUT` to `signedUrl`. Then `attachments.confirm(id)` checks that the object
exists, takes its size and type from Storage and writes
`attachment.uploaded` to the outbox. `confirm` returns
`not_found` with the hint `ATTACHMENT_NOT_UPLOADED` until the object exists.

`upload` takes `metadata`, a JSON object kept with the record in the
`metadata` column (a caption, where the file came from, a page count), and
every attachment returns it as `metadata` (`{}` without any).

`download(id)` returns a signed URL (300 seconds by default, `downloadTtl`
changes it; `{ as: "plan.pdf" }` sets the download name). Until the scan
passes it returns `invalid_request` with the hint `ATTACHMENT_NOT_SCANNED`.
`list(organizationId, { type, id })` lists a record's files oldest first,
and `remove(id)` deletes the object and then the row.

Files the server makes or fetches (an export, a generated PDF, an import
from a URL) go through `put(attachment, file)`, which creates the record,
uploads the bytes with the storage client and confirms it in one call.
`read(id)` returns the bytes as a `Blob` for server-side processing, behind
the same scan gate as `download` (`ATTACHMENT_NOT_SCANNED` until the scan
passes):

```ts
const report = await attachments
  .put(
    {
      organizationId,
      subjectType: "project",
      subjectId,
      name: "report.pdf",
      mimeType: "application/pdf",
      size: pdf.byteLength,
    },
    pdf,
  )
  .orThrow();

const { file } = await attachments.read(attachmentId).orThrow();
const text = await extractText(await file.arrayBuffer());
```

Pass the caller's Supabase client so the table and bucket policies apply;
`createAttachments` never needs the service role. A service-role client works
too, for jobs that act for no user.

## Scanning [#scanning]

`createAttachmentScanner` runs your scanner (ClamAV, a scanning API) on each
uploaded file with a service-role client, and records the verdict with
`set_attachment_status`, which only the service role can call.

```ts title="app/api/cron/attachments/route.ts"
import {
  createAttachmentScanner,
  sqlTransport,
} from "better-supabase/blocks/attachments";

const scanner = createAttachmentScanner({
  transport: sqlTransport(postgres.admin),
  storage: supabaseAdmin.storage,
  scan: async (file) => {
    const result = await clamav.scan(await file.arrayBuffer());
    return result.infected
      ? { status: "infected", detail: result.signature }
      : { status: "clean" };
  },
});

await outbox.relay("attachment-scans", scanner.sink());
```

`scanner.sink()` scans the file of each `attachment.uploaded` event, and
`scanner.job` is a [jobs](/docs/blocks/jobs) handler for an
`{ attachmentId }` payload when you prefer a queue. A file that is already
clean or infected is not scanned again, so replays are safe. When `scan`
throws, the file is marked `failed` with the error as `scan_detail`, the
result is an error with the hint `ATTACHMENT_SCAN_FAILED`, and the next
attempt scans it again. An event or job for an attachment that was deleted
before its scan is done: the sink skips it and the job completes, so one
deleted file never blocks the events behind it in the relay. Other errors,
such as a failed download or a throwing `scan`, still throw so the relay or
the queue retries.

### Any bucket [#any-bucket]

The scan gate also works without the attachments table, for a file drive,
avatars or generated exports. `scanned_objects` holds one row per object,
`object_clean(bucket, path)` answers in any storage policy, and
`createObjectScanner` records verdicts with `set_object_scan` (service role):

```sql
create policy "drive files are clean" on storage.objects for select to authenticated
  using (bucket_id = 'files' and better_supabase.object_clean(bucket_id, name) and ...);
```

```ts
const objects = createObjectScanner({
  transport: sqlTransport(postgres.admin),
  storage: supabaseAdmin.storage,
  scan: (file, { bucket, path }) => scanWithClamav(file),
});
await outbox.relay("object-scans", objects.sink());
```

With `scanBuckets: ["files"]`, a trigger on `storage.objects` gives each new
or replaced object in those buckets a `pending` row and writes an
`object.uploaded` event (`{ bucket, path }`), which `objects.sink()` scans;
`objects.job` takes the same payload from a queue. An object deleted from
storage before its scan (`NoSuchKey`) is skipped the same way; a missing
bucket or any other error still throws. Scanning an attachment also records
its object in `scanned_objects`, so both gates agree.

## Events [#events]

With the outbox installed, the module writes these events with the tenant as
the partition key and `attachments/<id>` as the subject:

| Event                 | When                                                   | Data                                                                         |
| --------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------- |
| `attachment.uploaded` | `confirm` saw the object                               | `attachmentId`, `subjectType`, `subjectId`, `uploadedBy`, `mimeType`, `size` |
| `attachment.scanned`  | a scan result was recorded                             | `attachmentId`, `status`, `uploadedBy`                                       |
| `object.uploaded`     | an object landed in a `scanBuckets` bucket (no tenant) | `bucket`, `path`                                                             |

# Audit log

> Record events, and list, reveal and export a tenant's audit entries as NDJSON, CSV or OCSF, and purge them per tenant's retention.

Source: https://bettersupabase.com/docs/blocks/audit

The `audit` SQL module records row changes and `audit_event()` calls (see
[SQL modules](/docs/blocks/sql#audit-log)). The audit block reads that log
from TypeScript: a list for an activity page, an export for customers and
SIEMs, and retention.

```bash
better-supabase sql add audit
```

Turn on `readPolicy` so members read their tenant's entries with
`audit.read`, and platform staff read every entry:

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: { modules: { audit: { options: { readPolicy: true } } } },
});
```

## Recording events [#recording-events]

`audit.record` writes a semantic event through `audit_event` and returns the
entry id. Call it with a service-role transport from a job, a webhook
handler or an API route:

```ts
const id = await audit
  .record({
    eventType: "ticket.escalated",
    category: "support",
    organizationId,
    record: ticket.id,
    summary: "Escalated to the on-call team",
    requestId: ctx.requestId,
    correlationId: ctx.correlationId,
    metadata: { ticketId: ticket.id, priority: 2 },
  })
  .orThrow();
```

Its fields are the arguments of `audit_event`. `actorId`, `actorKind`,
`actorLabel`, `ip`, `userAgent`, `sessionId`, `requestId` and `scope` count
only for the service role and direct admin connections; any other caller
gets its own user and the request's id. Pass `ctx.requestId` and
`ctx.correlationId` from the [server context](/docs/auth/server#request-and-correlation-ids)
so the event and the row changes of the same action share them; without
them, `audit_event` reads the request's settings and headers (see
[Request and correlation ids](/docs/blocks/sql#request-and-correlation-ids)).
An id that isn't 1 to 128 safe characters is dropped. `scope` defaults
to `tenant` with an organization and `platform` without one, and an adopted
log can take its own value (`region`, say). An adopted log's columns outside
the module's model are filled from metadata keys with
`sql.modules.audit.options.metadataColumns` (see
[SQL modules](/docs/blocks/sql#audit-log)). A key the event leaves out
leaves its column to the column's default, so a `not null default` column
keeps working, and the mapped keys are removed from the stored metadata
unless `keepMappedMetadata` is `true`. `list` and `export` return those
columns by name under `columns`; pass their types to `createAuditLog` to
type them:

```ts
const audit = createAuditLog<{ ticket_id: string | null; priority: number }>({
  transport: sqlTransport(ctx.postgres),
});
const page = await audit.list({ organizationId }).orThrow();
// page.entries[0].columns: { ticket_id: "...", priority: 2 }
```

A column that follows from what the module already records, such as a flag
for entries by platform staff, needs no metadata key: make it a generated
column of the adopted log. Postgres fills it for every entry, from
`audit_event` and from the row-change trigger alike, and the module never
writes it:

```sql
alter table public.audit_logs
  drop column actor_is_platform_admin,
  add column actor_is_platform_admin boolean not null
    generated always as (coalesce(actor_kind = 'platform_admin', false)) stored;
```

With `values: { actorKind: { support: "platform_admin" } }`, a support
session's entries and a service call with `actorKind: "support"` store
`platform_admin`, and the flag follows. An expression that needs another
table (a role lookup) can't be a generated column; pass that value as a
metadata key instead.

## Module actions [#module-actions]

Every SQL module records its security-relevant actions in the log once the
`audit` module is installed: a membership change, an API key, a setting, a
flag, a credential, a connector, a webhook secret or a support session each
write one entry, in the same transaction as the outbox event. The modules use
one set of categories:

| Category        | Actions                                                      |
| --------------- | ------------------------------------------------------------ |
| `membership`    | invitations, member roles, removals, ownership, the waitlist |
| `access`        | roles, permissions and overrides in the access catalog       |
| `security`      | API keys, credentials, provider keys, support sessions, SSO  |
| `configuration` | organizations, settings, flags, announcements, workflows     |
| `billing`       | billing customers                                            |
| `data`          | row changes from the table trigger, attachments, exports     |
| `ai`            | agents, tool policies and approvals, chat shares             |
| `integration`   | connectors, incoming and outgoing webhooks                   |

An adopted log whose `category` column has a check of its own maps the
shared names with `values`, the same way it maps actor kinds:

```ts title="better-supabase.config.ts"
audit: {
  options: {
    values: {
      category: { membership: "users", configuration: "settings" },
    },
  },
},
```

Set `audit: false` on a module to keep its actions out of the log; they
still reach the outbox:

```ts
sql: { modules: { flags: { audit: false } } },
```

`options.auditCategory` on `organizations`, `organizations-suspension` and
`support-sessions` still works and puts every entry of that module under one
category, but it is deprecated: map the shared categories with `values`
instead.

## Recording CloudEvents [#recording-cloudevents]

`audit.sink()` returns an `EventSink` that records each CloudEvent it
receives, so the events another part of the app already sends reach the log:

```ts
import { forwardBlockEvents } from "better-supabase/events";

forwardBlockEvents(betterSupabase, audit.sink(), {
  source: "/crm",
  types: ["organization.*", "support.*"],
});
```

`auditEventOf(event)` is the mapping it uses:

| Audit field      | From the CloudEvent                                                    |
| ---------------- | ---------------------------------------------------------------------- |
| `eventType`      | `type` without the `dev.better-supabase.` prefix (`typePrefix` option) |
| `category`       | `security` for `account.*` and `support.*` (`category` option)         |
| `record`         | `subject`                                                              |
| `organizationId` | the `tenant` or `partitionkey` extension, then `data.organizationId`   |
| `actorId`        | `data.actorId`                                                         |
| `metadata`       | `data`                                                                 |
| `idempotencyKey` | `source` and `id`, so a redelivered event is recorded once             |

`category` takes the event type without its prefix and returns one of the
module categories above, or `undefined` for none, so app events land next to
the module actions:

```ts
audit.sink({
  category: (type) => (type.startsWith("invoice.") ? "billing" : undefined),
});
```

`send` throws when an entry fails, after trying the rest, so an outbox relay
retries the batch. Build the log on a service-role transport: only the
service role can set the actor. The server's `audit` option takes the same
sink (see [Account deletion](/docs/auth/account-deletion#recording-account-actions-in-the-audit-log)).

## Listing entries [#listing-entries]

`createAuditLog` reads the log through the module's own functions, as the
caller, so the read policy decides what each member sees and an adopted log's
column mappings are already applied. It needs no generated types and no
exposed schema: pass `sqlTransport(ctx.postgres)` (direct Postgres as the
user), or `rpcTransport(supabase, { schema: "api" })` with the module's
[API schema](/docs/blocks/sql#calling-a-module-over-the-data-api).

```ts title="app/settings/audit/page.ts"
import { createAuditLog, sqlTransport } from "better-supabase/blocks/audit";

const audit = createAuditLog({ transport: sqlTransport(ctx.postgres) });

const page = await audit
  .list({ organizationId, eventType: "invoice.sent", since, limit: 50 })
  .orThrow();
// page.entries: id, occurredAt, eventType, actorId, actorLabel, summary, targetLabel, ...
const older = await audit.list({ organizationId, cursor: page.next });
```

`list` calls `list_audit_events` with the tenant, event type, actor, target
type, record, category, outcome, source, actor kind, correlation id and time
filters, newest first; pass
`page.next` as `cursor` for the next page. Every filter but the times takes
one value or a list, `search` matches text in the event type, summary,
target, actor and tenant labels, record and table (case-insensitive),
`order: "asc"` lists oldest first, and `count: true` adds `page.total`, the
number of entries the filters match, from `count_audit_events`:

```ts
const page = await audit
  .list({
    organizationId,
    eventType: ["invoice.sent", "invoice.paid"],
    outcome: "failure",
    search: "acme",
    order: "asc",
    count: true,
  })
  .orThrow();
// page.total: 12
```

A table with page numbers opens page N with `offset` next to `count`:

```ts
const page = await audit
  .list({
    organizationId,
    limit: 25,
    offset: (pageNumber - 1) * 25,
    count: true,
  })
  .orThrow();
// page.entries: the entries of that page; page.total: every match
```

`offset` skips that many entries after the filters (and after `before`, when
both are set), and `export` takes it too, to start an export at that entry.

`export` takes the same filters, `search` and `order`. Restricted details (row snapshots, IP, user agent) are
never in the list. `audit.reveal(entryId)` returns them for a member with the
reveal permission in the entry's tenant, or platform staff, and records the
look as an `audit.revealed` entry; anyone else gets `not_found`
(`AUDIT_ENTRY_NOT_FOUND`).

`auditListQuery` is the older path: a [list query](/docs/platform/list)
over the audit table with facets for `table`, `record`, `op`, `actor`,
`event`, `category`, `outcome` and `target`. It reads the table itself, so it
needs the table in your generated models and follows the managed column
names; use it with `ctx.sql` over direct Postgres, since the module schema is
not exposed to the Data API.

```ts
export const auditList = auditListQuery(betterSupabase, "auditEvents");
const page = await auditList
  .run(ctx.sql, query.value, {
    where: { organizationId, ...auditList.between(from, to) },
  })
  .orThrow();
```

## Exporting [#exporting]

`audit.export()` streams every entry the caller can read as NDJSON, CSV or
OCSF, one `list_audit_events` page at a time, so memory stays flat however
long the log is, and it reads an adopted log and the version 3 columns
(`actorLabel`, `summary`, `requestId` and the rest) like `list` does:

```ts title="app/api/audit/export/route.ts"
export const GET = bs.handler(async (request, ctx) => {
  const audit = createAuditLog({
    transport: sqlTransport(postgres.asUser(ctx.auth.claims)),
  });
  const stream = audit.export({ organizationId, since, format: "csv" });
  return new Response(stream, { headers: { "content-type": "text/csv" } });
});
```

`format: "csv"` writes a header row and one row per entry, with text that
starts like a spreadsheet formula prefixed with `'`. `columns` picks and
orders the columns (a record key, a key with its header label,
`columns.<name>` for a column `metadataColumns` fills, or
`restricted.<field>` for restricted details), `preamble` writes lines
before the header row, and `formatRow` changes each row before it is
written:

```ts
const stream = audit.export({
  organizationId,
  format: "csv",
  preamble: [`Audit log for ${organization.name}`, `Exported ${now}`],
  columns: [
    { key: "occurredAt", label: "Time" },
    { key: "actorLabel", label: "Who" },
    "eventType",
    { key: "columns.ticket_id", label: "Ticket" },
    { key: "restricted.ip", label: "IP address" },
  ],
  formatRow: (row, record) => ({
    ...row,
    occurredAt: record.occurredAt.toLocaleString("en-GB"),
  }),
});
```

A `restricted.<field>` column (`ip`, `userAgent`, `sessionId`, `metadata`,
`old`, `new` or `changes`) reads each page's details through
`reveal_audit_entries(entries)`, which returns them for the entries the
caller may reveal (the same check as `reveal`), leaves the cells of the
others empty, and records one `audit.revealed` entry per tenant with the
revealed ids in `metadata.entries`. It needs the `restricted` option and
the access module. Without `columns`, the export keeps its default
columns. For an export that runs
in the background (a jobs handler that emails a link, say),
`audit.exportToStorage({ storage, bucket, path, signedUrlTtl, ...filters })`
writes the file to Storage, such as the data lifecycle `data-exports`
bucket, and returns its path and a signed download URL.

`exportAuditLog(sql, options)` streams NDJSON or OCSF straight from the
managed table's columns. Keep it for a managed log; `audit.export()` covers
adopted logs and CSV.

`format: "ocsf"` maps each entry to an
[OCSF](https://schema.ocsf.io/1.9.0) event (`SPEC_PINS.ocsf`). Row changes
are Entity Management (`class_uid` 3004) with the table and record as the
`entity`; `audit_event()` entries are API Activity (6003) with the event type
as `api.operation`. The activity comes from the event type's last segment:
`created` is Create, `exported` is Read, `renamed` is Update and `revoked` is
Delete; anything else is Other (99) with the event type as `activity_name`.
`toOcsf(entry, product)` maps one entry.

## Retention [#retention]

`purgeAuditLog` deletes entries past their retention and returns how many.
Without `retention` it calls `purge_audit_log`, which honours an
`audit_retention` SQL hook; with it, each tenant is purged with its own
number of days. `setAuditRetention` reads those days from a column:

```ts title="jobs/purge-audit.ts"
import { purgeAuditLog, setAuditRetention } from "better-supabase/blocks/audit";

await purgeAuditLog(postgres.admin, {
  olderThan: "1 year",
  retention: setAuditRetention(postgres.admin, {
    table: "public.organizations",
    column: "audit_retention_days",
  }),
});
```

`purgeAuditLog` reads every tenant's days in one query. A tenant whose
column is `null` keeps the `olderThan` default. Run the job
until it returns 0; each call deletes at most `batch` entries per tenant.

# Billing

> Stripe customers per tenant, Checkout and the customer portal, seat sync from membership events, and Stripe webhook handling that links customers and refreshes sessions.

Source: https://bettersupabase.com/docs/blocks/billing

The `billing` block connects each tenant to a Stripe customer. It opens
Checkout and the customer portal, keeps a per-seat subscription's quantity in
step with the tenant's members, and handles the Stripe events that change a
plan. Subscriptions are read from the
[Stripe Sync Engine](/docs/blocks/entitlements) tables that `entitlements`
uses, so the block never calls Stripe to find out what a tenant pays for.

```bash
better-supabase sql add billing entitlements   # adds tenant and access as well
pnpm add stripe                                # optional: or pass your own client
```

`billing_customers` maps `organization_id` to `stripe_customer_id`, with a
foreign key to the tenant table (`billing_customers_tenant_fkey`, `on delete
cascade`), so a deleted tenant takes its row along. The key references the
[organizations](/docs/blocks/organizations) module's table when it is
installed; set `options.tenantKey` to `"schema.table.column"` for your own
tenant table, or to `false` for none. Existing rows without a tenant leave
the key unvalidated, with a warning. With
`billing` installed, the `entitlements` module reads it as its customer
source, so you don't set `entitlements.customer`. Members with `billing.read`
can read their tenant's row and `billing_status`; the default `admin` role
has `billing.*`. The other functions are granted to `service_role` only.

## Setup [#setup]

```ts title="src/lib/billing.ts"
import { createBilling, sqlTransport } from "better-supabase/blocks/billing";

export const billing = createBilling({
  stripe: { secretKey: env.STRIPE_SECRET_KEY },
  transport: sqlTransport(postgres.admin),
  events: bs.events, // billing.* block events
  seatPrice: env.STRIPE_SEAT_PRICE,
  sessions: {
    sql: postgres.admin,
    invalidate: (id) => bs.invalidateSession(id),
  },
});
```

`stripe` takes a secret key, which loads the optional `stripe` package with
its fetch HTTP client so it runs on every runtime, a Stripe client you
created, or a function that returns a client. The function runs on every
Stripe call, so it can create the client lazily, read a rotated key, or pick
a per-request client (a Stripe Connect account); cache inside it when the
client is the same each time:

```ts
let client: Stripe | undefined;
const billing = createBilling({
  stripe: () => (client ??= new Stripe(env.STRIPE_SECRET_KEY)),
  transport: sqlTransport(postgres.admin),
});
```

The [usage](/docs/blocks/usage) block takes the same `stripe` option. Check the caller's permission (`billing.manage`) in your route before
you call `checkout`, `portal` or `syncSeats`: they act as the service role.

## Checkout and the portal [#checkout-and-the-portal]

```ts title="app/billing/actions.ts"
const { url } = await billing
  .checkout(organizationId, {
    price: env.STRIPE_SEAT_PRICE,
    quantity: "seats", // the tenant's seat count
    successUrl: `${origin}/billing?done=1`,
    cancelUrl: `${origin}/billing`,
    email: user.email,
  })
  .orThrow();
redirect(url!);

const portal = await billing.portal(organizationId, {
  returnUrl: `${origin}/billing`,
});
```

`checkout` creates the Stripe customer the first time, with the tenant id in
`metadata.organization_id`, and links it. When two checkouts race, the first
link wins and both use that customer. The session carries the tenant in
`client_reference_id` and in the subscription's metadata, so the webhook can
link it even if the customer was created elsewhere. `params` merges more
Checkout Session parameters deeply: nested objects such as `metadata` and
`subscription_data` merge with the block's, and arrays such as `line_items`
replace them. The customer, `client_reference_id` and
`metadata.organization_id` (on the session and the subscription) are set
again afterwards, so your own metadata never drops the tenant link. `portal` returns `not_found`
(`BILLING_NO_CUSTOMER`) before the tenant has a customer.

`cancelSubscription(organizationId)` cancels the tenant's active subscription
in Stripe right away and returns its id, or `undefined` when there is none.
The [data lifecycle](/docs/blocks/data-lifecycle) purge calls it before it
deletes an organization.

### Plans from your catalog [#plans-from-your-catalog]

Most apps keep a plan catalog (plan keys, their prices per interval,
features). Point `options.plans` at it and checkout takes a plan key instead
of a price id:

```ts title="better-supabase.config.ts"
billing: {
  options: {
    plans: {
      table: "public.plan_prices", // or "plan_prices" in public
      key: "plan_key", // default key
      price: "stripe_price_id", // default stripe_price_id
      interval: "interval", // optional: month, year
      active: "is_active", // optional
      variant: "pack", // optional: several prices per plan key
    },
  },
},
```

```ts
await billing.checkout(organizationId, {
  plan: "pro",
  interval: "year",
  successUrl,
});
```

`billing_plan_price(plan, billing_interval, variant)` returns the price (the
monthly one when no interval is given), and an unknown plan fails with
`BILLING_PLAN_UNKNOWN`.

A plan key can have several prices besides the interval, such as credit
packs or seat tiers. Name the column that tells them apart in `variant`:
the row without a variant is the plan's default, and `variant` picks
another. `items` adds more line items to the same Checkout Session, each a
price or a plan key with its own `interval`, `variant` and `quantity`, for
add-on packages next to the plan:

```ts
await billing.checkout(organizationId, {
  plan: "pro",
  items: [{ plan: "credits", variant: "5000", quantity: 2 }],
  successUrl,
});
```

`changePlan` takes `variant` too.

## Managing a subscription [#managing-a-subscription]

These calls are for an admin console or the customer's own billing page. The
Stripe calls run with the block's Stripe client, so guard the route or action
with your own permission check (such as `billing.manage`).

```ts
await billing.changePlan(organizationId, { plan: "business" }); // or { price }
await billing.cancelAtPeriodEnd(organizationId, true); // false resumes it
const invoices = await billing
  .invoices(organizationId, { limit: 20 })
  .orThrow();
const methods = await billing.paymentMethods(organizationId).orThrow();
await billing.voidInvoice(organizationId, invoiceId);
await billing.markInvoiceUncollectible(organizationId, invoiceId);
```

`changePlan` moves the subscription item to the new price with prorations
(`prorationBehavior`) and clears a scheduled cancellation unless
`resume: false`. `invoices` and `paymentMethods` read the Stripe Sync
Engine's `stripe.invoices` and `stripe.payment_methods` rows as stored
(`billing_invoices`, `billing_payment_methods`), newest first, for callers
with `billing.read`; they return `[]` until the Sync Engine has the tables.
`voidInvoice` and `markInvoiceUncollectible` act only on the tenant's own
invoices (`BILLING_INVOICE_NOT_FOUND` otherwise).

### Subscriptions in full, and every tenant's billing [#subscriptions-in-full-and-every-tenants-billing]

`subscription(organizationId)` returns the tenant's newest subscription as
the Sync Engine stores it, every column with its `items`, preferring an
active one (`billing_subscription`). Platform staff and back-office jobs read
every tenant's newest subscription with `allSubscriptions`, newest first:

```ts
const current = await billing.subscription(organizationId).orThrow();

const page = await billing
  .allSubscriptions({ status: "past_due", limit: 50 })
  .orThrow();
// [{ organizationId, customerId, row }]
const next = await billing
  .allSubscriptions({
    status: "past_due",
    limit: 50,
    cursor: page.at(-1)?.row.created as number,
  })
  .orThrow();
```

`allInvoices({ status, limit, before })` does the same for invoices, across
tenants, as `{ organizationId, customerId, row }`, and platform staff also
read one tenant's `invoices` and `paymentMethods`.

These are open to platform staff with `billing.read` in the platform scope
(the module's `viewAll` permission, checked with `is_platform()`), and
`allSubscriptions` to the service role. `subscription` also answers members
with `billing.read` in the tenant.

An admin list that searches by tenant name, sorts by plan, period end or
amount, or counts rows needs typed columns it can join in SQL rather than
raw JSON pages. `billing_platform_subscriptions()` returns one row per
tenant (its newest subscription, preferring an active one) with `tenant`,
`customer`, `customer_email` and `customer_name` (from `stripe.customers`),
`subscription`, `status`, `price`, `price_metadata` (the price's Stripe
metadata), `plan` (the key from `options.plans`, when set), `quantity`,
`amount` (the price's unit amount times the quantity), `currency`,
`recurring_interval` (the price's `month`, `year` and so on),
`current_period_start`, `current_period_end`, `cancel_at_period_end` and
`created`. `billing_platform_invoices()` returns every invoice with
`tenant`, `customer`, `invoice`, `subscription`, `number`, `status`,
`amount_due`, `amount_paid`, `amount_remaining`, `total`, `currency`,
`due_date`, `finalized_at` and `paid_at` (from the invoice's status
transitions), `hosted_invoice_url`, `invoice_pdf`, `customer_email` and
`customer_name` (the invoice's, else the customer's), `created` and
`updated_at` (when the Sync Engine last wrote the row). Both read the Sync
Engine's tables when they exist, and are open to the service role and
platform staff with `viewAll`.

`plan` is `null` for a price that `options.plans` doesn't list. When your
plans keep their key in the price's metadata instead, fall back to it:

```sql
select s.tenant, coalesce(s.plan, s.price_metadata ->> 'plan') as plan
from better_supabase.billing_platform_subscriptions() s;
```

A list of subscriptions for platform staff:

```sql
select o.name, s.plan, s.status, s.current_period_end, s.amount
from better_supabase.billing_platform_subscriptions() s
join public.organizations o on o.id = s.tenant
where o.name ilike '%acme%'
order by s.current_period_end
limit 50;
```

`allSubscriptions` and `allInvoices` stay the paged JSON form of the same
reads.

A customer list includes tenants that have a customer but no subscription
or invoice yet. `billing_platform_customers()` returns one row per linked
customer with `tenant`, `customer`, `email`, `name` and `created` (from
`stripe.customers`, null before the Sync Engine has the row), for the
service role and platform staff with `viewAll`. In TypeScript,
`allCustomers()` reads it through `billing_all_customers()`:

```ts
const customers = await billing.allCustomers().orThrow();
// [{ organizationId, customerId, email, name, created }]
```

### The billing contact [#the-billing-contact]

Stripe is the record for the billing contact: email, name, address, phone and
tax ids live on the Stripe customer, the Sync Engine mirrors them into
`stripe.customers`, and invoices use them. `customerDetails(organizationId)`
reads that row (`billing_customer_details`), and
`updateCustomer(organizationId, { email, name, address, params })` writes the
customer, creating it first when the tenant has none. There is no second copy
in the module's tables to keep in sync.

Tax ids (VAT, GST and the other Stripe tax id types) live on the Stripe
customer too. `taxIds(organizationId)` lists them, `addTaxId(organizationId, { type, value })` adds one (creating the customer when the tenant has none),
and `removeTaxId(organizationId, taxId)` removes one. Stripe validates the
value and reports `verification.status` for the types it checks:

```ts
await billing.addTaxId(organizationId, {
  type: "eu_vat",
  value: "DE123456789",
});
const taxIds = await billing.taxIds(organizationId).orThrow();
// [{ id, type, value, country, verification, created }]
```

They need `customers.listTaxIds`, `createTaxId` and `deleteTaxId` on the
Stripe client, which the `stripe` package has. Guard them with your own
`billing.manage` check, as the other Stripe calls.

`taxIds(organizationId, { from: "sync" })` reads the Sync Engine's
`stripe.tax_ids` instead, without a Stripe call, through
`billing_tax_ids(tenant)`. That function checks `billing.read` (or platform
staff) itself and returns the same shape, newest first, with `created` in
epoch seconds as Stripe reports it (`null` when the row has none), so SQL
can use it too, for example in an admin list:

```sql
select t ->> 'type' as type, t ->> 'value' as value,
  t ->> 'country' as country, t -> 'verification' ->> 'status' as verification
from jsonb_array_elements(better_supabase.billing_tax_ids(tenant)) t;
```

It returns `[]` before the tenant has a customer or the Sync Engine has the
`tax_ids` table.

### Reading a subscription in your own functions [#reading-a-subscription-in-your-own-functions]

Every reader above checks the caller, so a function that resolves the plan
for an ordinary member (say, to apply a limit) can't use them.
`better_supabase.billing_tenant_subscription(tenant)` returns what
`billing_subscription` returns without that check. No role may execute it,
not even `service_role`, and `api` writes no entry point for it: only
functions owned by the module's owner, such as your `security definer`
functions created by `postgres`, call it.

```sql
create function public.plan_price(tenant uuid)
returns text language sql stable security definer set search_path = '' as $$
  select better_supabase.billing_tenant_subscription(tenant) -> 'items' -> 0 ->> 'price'
$$;
```

Check what the caller may see in that function before you return anything
beyond what a member may know.

### Audit retention by plan [#audit-retention-by-plan]

The [audit](/docs/blocks/audit) module calls an `audit_retention(tenant)`
function when you write one, so retention can follow the plan the Sync
Engine reports:

```sql
create function public.audit_retention(tenant uuid)
returns interval language sql stable security definer set search_path = '' as $$
  select case p.price_id
    when 'price_enterprise' then interval '365 days'
    when 'price_business' then interval '90 days'
    else interval '7 days'
  end
  from (
    select (better_supabase.billing_subscription_item(tenant) ->> 'price') as price_id
  ) p
$$;
```

## Seats [#seats]

`billing_seat_count(tenant)` counts the tenant's memberships, or only those
with a role in `options.seatRoles`:

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      billing: { options: { seatRoles: ["owner", "admin", "member"] } },
    },
  },
});
```

`syncSeats(organizationId)` sets the active subscription item's quantity
(the `seatPrice` item, or the first one) to that count. Relay the membership
events from the [outbox](/docs/blocks/outbox) to `seatSink()` to keep it in
step:

```ts title="app/api/cron/outbox/route.ts"
await outbox.register("billing-seats", {
  types: [
    "organization.member_added",
    "organization.member_removed",
    "organization.member_left",
    "organization.role_changed",
  ],
});
export const GET = outbox.relayRoute({
  secret: env.CRON_SECRET,
  consumers: { "billing-seats": billing.seatSink() },
});
```

The sink syncs each tenant once per batch. The Stripe idempotency key holds
the item, the old and new quantity and the outbox event id, so a relay that
retries the batch doesn't change the subscription twice. A failed update
throws, which leaves the batch for the next run.

## Stripe webhooks [#stripe-webhooks]

Store Stripe events in the webhook inbox, then hand them to
`handleStripeEvent`:

```ts title="app/api/stripe/route.ts"
import { stripeInboxVerify } from "better-supabase/blocks/billing";
import { createWebhookInbox } from "better-supabase/blocks/jobs";

export const inbox = createWebhookInbox(postgres.admin, {
  source: "stripe",
  verify: stripeInboxVerify(env.STRIPE_WEBHOOK_SECRET),
});
export const POST = (request: Request) => inbox.receive(request);

// In the inbox cron route:
await inbox.process((message) =>
  billing.handleStripeEvent(message.payload).orThrow(),
);
```

| Stripe event                    | What the block does                                                                                            |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `checkout.session.completed`    | Links the customer to the tenant; emits `billing.checkout_completed`                                           |
| `customer.subscription.created` | Links from `metadata.organization_id` if needed; emits `billing.subscription_created` and invalidates sessions |
| `customer.subscription.updated` | Emits `billing.subscription_updated` and invalidates sessions                                                  |
| `customer.subscription.deleted` | Emits `billing.subscription_deleted` and invalidates sessions                                                  |

Other events are ignored. Session invalidation uses
[`entitlementMembers`](/docs/blocks/entitlements), so members read the new
plan's entitlements on their next request. `verifyStripeWebhook` checks the
`Stripe-Signature` header with WebCrypto, without the `stripe` package.

## Block events [#block-events]

`billing.customer_linked`, `billing.checkout_completed`,
`billing.subscription_created`, `billing.subscription_updated`,
`billing.subscription_deleted` and `billing.seats_synced` carry
`organizationId` and, where they apply, `customerId`, `subscriptionId`,
`status`, `quantity`, `previousQuantity` and `stripeEventId`. Listen with
`onBlockEvent(bs, "billing.*", handler)`.

## Functions [#functions]

| Function                                                          | Granted to                      | Returns                                                                                     |
| ----------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------- |
| `billing_customer(tenant)`                                        | `authenticated`, `service_role` | the customer id, for callers with `billing.read`                                            |
| `billing_status(tenant)`                                          | `authenticated`, `service_role` | `{ customer, seats, subscription }`                                                         |
| `billing_subscription(tenant)`                                    | `authenticated`, `service_role` | the newest `stripe.subscriptions` row with `items`, for `billing.read` or platform staff    |
| `billing_tenant_subscription(tenant)`                             | none (the owner)                | the same row without checking the caller, for your own `security definer` functions         |
| `billing_all_subscriptions(for_status, max_rows, before_created)` | `authenticated`, `service_role` | `[{ tenant, customer, subscription }]`, for platform staff                                  |
| `billing_all_invoices(for_status, max_rows, before_created)`      | `authenticated`, `service_role` | `[{ tenant, customer, invoice }]`, for platform staff                                       |
| `billing_platform_customers()`                                    | `authenticated`, `service_role` | every linked customer: `tenant`, `customer`, `email`, `name`, `created`, for platform staff |
| `billing_all_customers()`                                         | `authenticated`, `service_role` | the same rows as `jsonb`, for `allCustomers()`                                              |
| `link_billing_customer(tenant, customer)`                         | `service_role`                  | the linked customer                                                                         |
| `billing_customer_tenant(customer)`                               | `service_role`                  | the tenant id                                                                               |
| `billing_seat_count(tenant)`                                      | `service_role`                  | the seat count                                                                              |
| `billing_subscription_item(tenant, price)`                        | `service_role`                  | `{ subscription, item, price, quantity, status }` or null                                   |
| `billing_plan_price(plan, billing_interval, variant)`             | `authenticated`, `service_role` | the plan's Stripe price from `options.plans`, or null                                       |
| `billing_invoices(tenant, max_rows)`                              | `authenticated`, `service_role` | the customer's `stripe.invoices` rows, for `billing.read`                                   |
| `billing_payment_methods(tenant)`                                 | `authenticated`, `service_role` | the customer's `stripe.payment_methods` rows                                                |
| `billing_customer_details(tenant)`                                | `authenticated`, `service_role` | the customer's `stripe.customers` row                                                       |
| `billing_tax_ids(tenant)`                                         | `authenticated`, `service_role` | the customer's `stripe.tax_ids` as `[{ id, type, value, country, verification, created }]`  |

# Comments and activity

> Threaded comments on any record in a tenant, mentions that notify, comment events in the outbox and an activity feed built from outbox events.

Source: https://bettersupabase.com/docs/blocks/comments

The `comments` block adds threaded comments to any record in a tenant
(projects, tickets, invoices) and an activity feed. Mentions notify the
mentioned members through the [notifications](/docs/blocks/notifications)
block, and every comment writes a `comment.*` event to the
[outbox](/docs/blocks/outbox).

```bash
better-supabase sql add comments   # adds tenant and access as well
```

| Table              | Holds                                                                                                        |
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
| `comments`         | `subject_type`, `subject_id`, `author_id`, `body`, `mentions`, `parent_id`, `edited_at` and `deleted_at`     |
| `activity_entries` | One row per outbox event: `type`, `actor_id`, `subject_type`, `subject_id`, `summary`, `data`, `occurred_at` |

| Permission          | Lets a member                       | Default roles          |
| ------------------- | ----------------------------------- | ---------------------- |
| `comments.read`     | read the comments in a tenant       | `member`, `admin`      |
| `comments.create`   | comment and edit their own comments | `member`, `admin`      |
| `comments.moderate` | delete other members' comments      | `admin` (`comments.*`) |
| `activity.read`     | read the tenant's activity feed     | `member`, `admin`      |

Only the author edits a comment. Deleting is a soft delete: the row stays
as a placeholder so replies keep their parent, and its body and mentions
are cleared.

## Subjects [#subjects]

Without options, a comment can name any `subject_type` and `subject_id`.
Map each subject type to its table so the policies also require that the
caller can read the subject row (the subject table's own policies apply)
and, optionally, hold a permission:

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      comments: {
        options: {
          subjects: {
            project: { table: "projects", permission: "projects.read" },
            ticket: {
              table: "support.tickets",
              id: "ticket_id",
              tenant: "team_id",
            },
          },
        },
      },
    },
  },
});
```

`id` defaults to `id` and `tenant` to `organization_id`. A subject type not in
the map is refused. `maxBodyLength` (default 10000) caps the body.

`permissions` gives a subject type its own keys per action, in place of the
module's `comments.read`, `comments.create` and `comments.moderate`, so
comments on deals follow the deal permissions while other subjects keep the
defaults:

```ts
subjects: {
  deal: {
    table: "deals",
    permissions: { read: "deals.read", create: "deals.comment", moderate: "deals.manage" },
  },
},
```

`read` decides who sees the thread, `create` who may comment, and `moderate`
who may edit or delete other people's comments; authors always edit their
own.

`cascade: true` on a subject writes an `after delete` trigger on its table
that deletes the subject's comments, which stands in for a foreign key, since
`subject_id` is text. The subject table must exist before the module file.

## Rich text [#rich-text]

A comment can carry a `document` (any JSON, such as a block editor's
document) next to `body`, which stays its plain text for search, summaries
and notifications. `createComments({ mentionsOf })` reads the mentioned user
ids from the comment when a call passes no `mentions`, for editors that store
mentions as nodes instead of `@[Name](<user id>)` markup:

```ts
const comments = createComments({
  transport,
  mentionsOf: ({ document }) =>
    mentionNodes(document).map((node) => node.userId),
});

await comments.create({
  organizationId,
  subjectType,
  subjectId,
  body,
  document,
});
await comments.edit(id, { body, document: null }); // null removes the document
```

An edit without `document` keeps the current one. With the jsonb-schemas
module, `options.documentSchema` (a JSON Schema object) adds a pg\_jsonschema
check on the column.

## Copying a thread [#copying-a-thread]

When the app duplicates a record (a quote into an invoice, say),
`comments.copy(organizationId, { type, id }, { type, id })` copies the
thread to the new subject with its authors, times and replies, and returns
the number of comments copied. `copy_comments` is for the service role only:
call it from the code that duplicates the record, after that code checked the
caller may read both. It works over `sqlTransport` and over `rpcTransport`
with a service-role client; PostgREST sessions on Supabase load
pg-safeupdate, which refuses a `delete` or `update` without a `where`
clause, and every statement the SQL modules run has one. The function
creates no temporary table, so `supabase db lint` checks it like any other.

## Comments [#comments]

```ts title="app/projects/[id]/actions.ts"
import { createComments, sqlTransport } from "better-supabase/blocks/comments";

const comments = createComments({
  transport: sqlTransport(postgres.asUser(claims)),
});

await comments
  .create({
    organizationId,
    subjectType: "project",
    subjectId: projectId,
    body: "Ready for review @[Ada](8c5a3d3e-...)",
  })
  .orThrow();

const thread = await comments
  .list(organizationId, "project", projectId)
  .orThrow();
```

`create` and `edit` read the mentions from the body when you pass none:
`mentionsIn(body)` returns the user ids written as `@[Name](<user id>)`, the
markup most mention inputs produce. The module drops the author and repeats,
and notifies only mentioned members who hold `comments.read`. `list` returns
the thread oldest first; `cursor` (an instant) polls for new comments, and `offset` with
`limit` pages by number. `counts(organizationId, subjectType, subjectIds)`
returns how many comments each subject has that the caller can read
(`comment_counts`), deleted ones left out and 0 for a subject without any,
for a counter on each row of a list. `edit` returns
`not_found` for a comment the caller can't see, and `forbidden` with the hint
`COMMENT_NOT_AUTHOR` for someone else's comment.

With the notifications module installed, each new mention sends a
`comment.mentioned` notification with the subject, the first 140 characters
of the body as its `summary` and the commenter as the actor. Three more keys
of `options.subjects.<type>` shape it, each SQL on the subject's row
`{row}`:

```ts
subjects: {
  task: {
    table: "public.tasks",
    idType: "uuid",
    label: "{row}.title",
    path: "'/tasks/' || {row}.id",
    readableBy: "not {row}.private or {row}.owner_id = {user}",
  },
},
```

`label` fills the notification's `subject_label` and `path` its
`action_path`, which an adopted events table may require. A mentioned member
is notified only when they hold the subject type's read key
(`permissions.read`, else `comments.read`) and its `permission` in the
tenant, and `readableBy` holds for them (`{user}` is their id). The others
stay in the comment's `mentions` but get nothing.

### Group mentions [#group-mentions]

`options.mentionGroups` expands a mention of a group, such as `@team`, into
its members. It names a table with one row per member of a group, the group
and member columns, and optionally a tenant column that must match the
comment's tenant:

```ts title="better-supabase.config.ts"
comments: {
  options: {
    mentionGroups: {
      table: "public.team_members",
      group: "team_id",
      member: "user_id",
      tenant: "organization_id",
    },
  },
},
```

The app writes the group id into `mentions` like a user id, for example as
`@[Support](<team id>)`. The trigger adds the members of every mentioned
group to the mentioned users, leaves out the author, and then applies the
read check above to each of them, so a group member who can't read the
subject gets nothing. On an edit, only members who were not already reached
through the old mentions are notified, and `comment.mentioned` carries the
expanded user ids in `mentionIds`. Both columns hold `uuid`s, and the
comment's `mentions` keep the group id as written.

`options.notify: false` sends no notification at all, for an app that sends
its own mention notifications. The outbox still gets `comment.mentioned`,
so that app can send from the event.

## Events [#events]

With the outbox installed, the module writes these events with the tenant as
the partition key and `comments/<id>` as the subject:

| Event               | When                           | Data                                                                          |
| ------------------- | ------------------------------ | ----------------------------------------------------------------------------- |
| `comment.created`   | a comment or reply is added    | `commentId`, `subjectType`, `subjectId`, `authorId`, `parentId`, `mentionIds` |
| `comment.mentioned` | a create or edit adds mentions | the same, with only the new `mentionIds`                                      |
| `comment.deleted`   | a comment is deleted           | `commentId`, `subjectType`, `subjectId`, `authorId`                           |

## Activity feed [#activity-feed]

`activitySink` is an outbox sink that writes events into `activity_entries`.
Run it as a consumer with a service-role transport:

```ts title="app/api/cron/activity/route.ts"
import { activitySink, sqlTransport } from "better-supabase/blocks/comments";

await outbox.relay(
  "activity",
  activitySink({
    transport: sqlTransport(postgres.admin),
    types: ["comment.*", "organization.member_added", "billing.*"],
    describe: (event, type) =>
      type === "comment.created" ? { summary: "commented" } : {},
  }),
);
```

Events without a tenant are skipped, and the event id is unique, so a
replayed batch writes nothing twice. The actor is the event's `actorId`,
`authorId` or `userId`, and the subject its `subjectType` and `subjectId`
(or the CloudEvents `subject`). `describe` can set the summary, actor,
subject and data, or return `null` to skip an event.

`comments.history(organizationId, subject?, { before, limit })` reads the
feed through `list_activity`, which runs as the caller: the tenant's entries
newest first, or one subject's timeline when you pass `{ type, id }`. It works
over `rpcTransport` too, without exposing the module schema.

```ts
const timeline = await comments
  .history(organizationId, { type: "quote", id: quoteId }, { limit: 20 })
  .orThrow();
```

Or read the feed with `activityListQuery`, a cursor-paged
[list query](/docs/platform/list) filtered by type, actor and
subject, newest first. Generate types for the `better_supabase` schema to
have the table in your models:

```ts
import { activityListQuery } from "better-supabase/blocks/comments";

export const activity = activityListQuery(betterSupabase, "activityEntries", {
  pageSize: 30,
});
```

# Connectors

> MCP servers an organization connects, each user's OAuth grant behind a credential reference, MCP sessions per chat and tool list fingerprints an admin approves.

Source: https://bettersupabase.com/docs/blocks/connectors

The `connectors` block stores the MCP servers an organization lets its
assistants call. Tokens never sit in these tables: a grant holds a
`credential_ref` that a [credential provider](/docs/extending/credentials)
resolves, and removing a grant or its server revokes the credential.

```bash
better-supabase sql add connectors   # adds tenant and access as well
```

| Table                         | Holds                                                                 |
| ----------------------------- | --------------------------------------------------------------------- |
| `connector_servers`           | The server URL, transport (`http` or `sse`), auth type and scopes     |
| `connector_grants`            | One active grant per user and server, with its `credential_ref`       |
| `connector_sessions`          | The MCP session id and initialize result per user, server and chat    |
| `connector_tool_fingerprints` | A digest of each tool list the server returned, and its review status |

A server's auth type is `none`, `header` (an app credential sent as
headers) or `oauth` (one grant per user). A `header` server's
`credentialRef` must carry the server's tenant
([tenant refs](/docs/extending/credentials#tenant-refs)), or the save fails
with `CREDENTIAL_REF_FOREIGN`. Server URLs must use `https`
unless `allowHttp` is on, and sessions expire after `sessionTtl` (`1 hour`
by default).

Exports leave out `credential_ref` on servers and grants. The organization
purge in [data lifecycle](/docs/blocks/data-lifecycle#credentials) revokes a server's credential and each grant's (for the
grant's user) before it deletes the rows, when the ref carries the
organization; a grant ref without a tenant comes back in
`credentials.unrevoked`.

| Permission  | Lets a member                      | Default roles              |
| ----------- | ---------------------------------- | -------------------------- |
| `ai.read`   | see the organization's servers     | `owner`, `admin`, `member` |
| `ai.create` | connect and call a server          | `owner`, `admin`, `member` |
| `ai.admin`  | add servers and approve tool lists | `owner`, `admin`           |

Rename the keys with `sql.modules.connectors.permissions.read`, `.use` and
`.manage`.

## Server [#server]

```ts title="lib/connectors.ts"
import { vaultCredentials } from "better-supabase/credentials";
import {
  createConnectors,
  rpcTransport,
} from "better-supabase/blocks/connectors";

export const connectorsFor = (
  supabase: SupabaseClient,
  admin: SupabaseClient,
) =>
  createConnectors({
    transport: rpcTransport(supabase),
    service: rpcTransport(admin),
    credentials: vaultCredentials({ transport: rpcTransport(admin) }),
  });
```

| Group          | Methods                                     |
| -------------- | ------------------------------------------- |
| `servers`      | `list`, `get`, `create`, `update`, `remove` |
| `grants`       | `record`, `revoke`, `expiring`, `renew`     |
| `sessions`     | `get`, `save`, `forget`, `purge`            |
| `fingerprints` | `check`, `approve`, `reject`                |

`servers.get(id)` returns the caller's active grant with the server.
`grants.record` runs as the service role after the provider stored the
credential, and revokes the grant it replaces. `grants.expiring(before)`
lists grants to renew.

## Tool list changes [#tool-list-changes]

A server can change its tools at any time. `fingerprints.check` records the
digest of the tool list and returns its status: the first list of a server
is approved, a later different list waits as `pending` until an admin
approves or rejects it. With `trustFirstUse: false` the first list waits
too. Until then the assistant gets none of the server's
tools.

## AI SDK [#ai-sdk]

[`better-supabase/ai-sdk/mcp`](/docs/ai-sdk/mcp) runs the OAuth flow,
connects to a server with the stored session and returns its tools.

# createBlocks

> Build several blocks from one set of options, and wire the siblings they share, from better-supabase/blocks.

Source: https://bettersupabase.com/docs/blocks/create-blocks

`createBlocks` builds the blocks you list with one transport, schema and set
of error mappers, and passes each block the siblings it can use: the AI
files block to knowledge, a credential provider to connectors, ai-providers
and the workflow builder, the notifications block to ai-tasks, and block
events to an audit sink. You pass the block creators yourself, so only the
blocks you use end up in the bundle.

```ts title="src/lib/blocks.ts"
import {
  type RpcClient,
  createBlocks,
  rpcTransport,
} from "better-supabase/blocks";
import { createAuditLog } from "better-supabase/blocks/audit";
import { createNotifications } from "better-supabase/blocks/notifications";
import { createOrganizations } from "better-supabase/blocks/organizations";

export function blocks(supabase: RpcClient) {
  return createBlocks(
    { transport: rpcTransport(supabase, { schema: "api" }), schema: "api" },
    {
      organizations: createOrganizations,
      audit: createAuditLog,
      notifications: (options) =>
        createNotifications({ ...options, types, render }),
    },
  );
}

const { organizations, audit, notifications } = blocks(supabase);
```

Each value in the second argument is a factory that gets the shared options
(a `BlockContext`) and returns a block. A block creator whose options are the
shared ones, such as `createOrganizations`, works as it is; wrap the ones that
need more, such as the notification `types`. The result has one key per
factory, typed from what the factory returns, plus `close()`.

## Options [#options]

| Option               | What it does                                                                                 |
| -------------------- | -------------------------------------------------------------------------------------------- |
| `transport`          | Calls as the caller: `rpcTransport(supabase)` or `sqlTransport(ctx.postgres)`                |
| `service`            | Calls as the service role, for the blocks with service-only calls                            |
| `schema`             | The module schema, or the API schema the wrappers live in                                    |
| `mappers`            | Error mappers that run before each block's built-in ones                                     |
| `temporal`           | The Temporal namespace for runtimes without a global one                                     |
| `credentials`        | A `CredentialProvider` for connectors, ai-providers and the workflow builder                 |
| `events`             | `betterSupabase.events`, for the blocks that emit [block events](/docs/extending/events)     |
| `audit`              | An `EventSink`, such as `auditLog.sink()`, that every block event goes to; needs `events`    |
| `aiTaskNotification` | The notification type ai-tasks sends through the `notifications` block for each finished run |

## Siblings [#siblings]

| Factory key     | Goes to                                | As                             |
| --------------- | -------------------------------------- | ------------------------------ |
| `aiFiles`       | every other block                      | `files`, which knowledge reads |
| `notifications` | every block, with `aiTaskNotification` | `notify`, which ai-tasks calls |

With `aiTaskNotification`, `notify` sends that type to the task's user in
the task's organization, keyed by the run id, with the data
`{ taskId, runId, ok, error?, chatId? }`. The type's schema in your
notification types has to accept that data.

```ts
const { aiTasks } = createBlocks(
  { transport, service, aiTaskNotification: "ai_task.finished" },
  {
    aiTasks: (options) => createAiTasks({ ...options, run }),
    notifications: (options) =>
      createNotifications({ ...options, types, render }),
  },
);
```

## Hooks [#hooks]

`hooks`, keyed by block name and then method name, runs your code around a
block's methods: a `before` hook can refuse a call or replace its arguments,
and an `after` hook observes a copy of the result. Siblings get the hooked
block, so a `send` hook also runs when ai-tasks notifies. See
[Extending blocks](/docs/extending/blocks#hooks).

```ts
export const app = createBlocks(
  {
    transport,
    hooks: { notifications: { send: { before: refuseDuringQuietHours } } },
  },
  { notifications: (options) => createNotifications({ ...options, types }) },
);
```

## Forwarding block events to the audit log [#forwarding-block-events-to-the-audit-log]

With `events` and `audit`, every block event becomes a CloudEvent with the
source `/better-supabase/blocks` (`BLOCK_EVENT_SOURCE`) and goes to the sink.
[`audit.sink()`](/docs/blocks/audit#recording-cloudevents) records each one in
the audit log. The forwarding listens on `events` until you call `close()`,
so build these blocks once, at module scope, rather than per request.

```ts
const log = createAuditLog({ transport: service });

export const app = createBlocks(
  { transport: service, events: betterSupabase.events, audit: log.sink() },
  { organizations: createOrganizations },
);
```

# Data lifecycle

> Data exports for a user or an organization as NDJSON files in Storage, and organization deletion with a grace period, a cancel and a purge job.

Source: https://bettersupabase.com/docs/blocks/data-lifecycle

The `data-lifecycle` block covers two requests every SaaS gets: "send me my
data" and "delete our account". Exports collect a user's or an
organization's rows from every installed module and your own tables into
one NDJSON file per table in a private bucket. Deleting an organization
disables the tenant right away and purges its data after a grace period,
so an owner can still cancel.

```bash
better-supabase sql add data-lifecycle   # adds tenant and access as well
```

| Table                    | Holds                                                                                         |
| ------------------------ | --------------------------------------------------------------------------------------------- |
| `data_exports`           | `subject` (`user` or `organization`), `status`, the object paths in `files`, and `expires_at` |
| `organization_deletions` | `requested_by`, `requested_at`, `purge_after`, `cancelled_at` and `purged_at`                 |

| Permission            | Lets a member                       | Default roles    |
| --------------------- | ----------------------------------- | ---------------- |
| `organization.export` | export the organization's data      | `owner`, `admin` |
| `organization.delete` | request the organization's deletion | `owner`          |

Every signed-in user can export their own data.

## Tables [#tables]

The module reads the tables of every other installed module from the module
registry: each module declares which of its tables hold a user's rows, which
hold a tenant's rows, and which the purge keeps. That covers memberships and
permission overrides, profiles, settings, comments and activity,
attachments, API keys, usage counters, events, quotas and history,
notifications with their deliveries, subscriptions and preferences,
onboarding progress, flag overrides, invitations, billing customers, SSO
domains, providers and SCIM users and members, support sessions, invite
codes, platform role assignments, outbox events, webhook endpoints,
deliveries and secrets, incoming webhook endpoints, inbox tables,
announcement dismissals, waitlist entries and redemptions, AI provider
keys, connectors and their grants, workflow credentials, chat installations
and the audit trail. A module added in a later release joins exports and purges
when you run `sql sync`, and a table you map to another name in
`sql.modules.<name>.tables` is read under that name. The module's own export
and deletion records stay out, API keys and webhook secrets are never
exported, and incoming webhook endpoints and invitations are exported without
their token hashes and secrets. Audit events are exported without the row
snapshots (`old_record`, `new_record`) and the impersonation details, which
describe other people's data and the support staff. Columns that hold a
`credential_ref` are never exported, and the purge revokes them first (see
[Purging](#purging)). An actor's outbox events are left out of user
exports, because their payloads describe other members too; the purge
deletes them with the tenant. Add your own tables with their user column (for user
exports) and tenant column (for organization exports and the purge):

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      "data-lifecycle": {
        options: {
          tables: {
            projects: { tenant: "organization_id", user: "owner_id" },
            "billing.invoices": { tenant: "organization_id", purge: false },
          },
          grace: "30 days",
          exportTtl: "7 days",
          bucket: "data-exports",
        },
      },
    },
  },
});
```

`purge: false` keeps a table's rows in the purge, for records you must
retain. `omit: ["api_token", "password_hash"]` leaves columns out of every
export file of that table, for credentials the export must not hand out;
the purge still deletes the rows. `export: false` keeps the whole table out
of exports, for tables that hold only credentials, while the purge still
deletes its rows. An entry for a table a module already contributes, such as
`"better_supabase.invitations"`, replaces that module's entry, so you can
leave out more columns or keep it out of exports. The audit trail is never purged; its
[retention](/docs/blocks/audit) removes old events.

A schema with many tenant tables doesn't need a list. `autoTables` adds every
table in its schemas that has the tenant column (and, with `user`, the user
column), read from the catalog when an export or a purge runs, so a new table
is covered without a config change. `tables` entries still win for the tables
they name, and `exclude` takes `schema.table` names or `*` globs:

```ts
options: {
  autoTables: {
    schemas: ["public"], // default
    tenant: "organization_id", // default
    user: "created_by",
    exclude: ["public.*_archive", "public.invoices"],
    purge: true,
  },
  tables: { "public.invoices": { tenant: "organization_id", purge: false } },
},
```

`tables: "auto"` is short for `autoTables: {}`.

## Exports [#exports]

`createDataExporter({ format: "csv" })` writes one CSV file per table instead
of NDJSON (`{id}/{schema.table}.csv`), with a header row, nested values as
JSON and text that starts like a formula prefixed with `'`, for customers who
open exports in a spreadsheet.

```ts title="app/settings/data/actions.ts"
"use server";

import {
  createDataLifecycle,
  rpcTransport,
} from "better-supabase/blocks/data-lifecycle";

const supabase = await createServerClient();
const lifecycle = createDataLifecycle({
  transport: rpcTransport(supabase),
  storage: supabase.storage,
});

await lifecycle.requestExport().orThrow();
await lifecycle.requestExport({ organizationId }).orThrow();
```

A request returns the open export for the same subject instead of starting
a second one, and writes `data_export.requested` to the
[outbox](/docs/blocks/outbox). The exporter runs it with a service-role
client: it reads each table in pages, writes `{id}/{schema.table}.ndjson`
and marks the export ready.

```ts title="app/api/cron/exports/route.ts"
import {
  createDataExporter,
  sqlTransport,
} from "better-supabase/blocks/data-lifecycle";

const exporter = createDataExporter({
  transport: sqlTransport(postgres.admin),
  storage: supabaseAdmin.storage,
});

await outbox.relay("data-exports", exporter.sink());
```

The exporter writes `concurrency` tables at a time (4 by default), each in
pages of `pageSize` rows (1,000), and the file list keeps the table order.
A tenant with hundreds of tables then exports in a fraction of the time it
takes one table after another. With a `postgres.admin` pool each table
reads on its own connection, so keep `concurrency` below the pool size;
`concurrency: 1` restores the sequential order. The first failing table
stops the others from starting, and the export is marked failed.

`exporter.job` is a [jobs](/docs/blocks/jobs) handler for an `{ exportId }`
payload when you prefer a queue. A failure marks the export `failed` with
the error, and running it again starts over. An event or job for an export
that is no longer pending or failed (cancelled, deleted, already running or
ready) is done: the sink skips it and the job completes, so it never blocks
the events behind it in the relay. `exporter.run()` still returns `not_found`
with the hint `DATA_EXPORT_NOT_FOUND` for it.

`data_export.completed` carries the requester and the files, so a
[notifications](/docs/blocks/notifications) consumer can tell them it's
done. `lifecycle.download(id)` then returns a signed URL per file. The
bucket's policy serves the files only to the requester (or members with
`organization.export` for an organization export) until `expires_at`.

Expired exports stay in Storage until something removes them. Run
`purger.purgeExports()` from the same cron job as the purge (a
service-role purger with `storage`): it removes the files of exports past
`expires_at` (`expired_data_exports`), then their rows
(`forget_data_exports`), and returns how many it removed.

## Deleting an organization [#deleting-an-organization]

```ts
const deletion = await lifecycle
  .requestOrganizationDeletion(organizationId, { grace: "14 days" })
  .orThrow();

await lifecycle.cancelOrganizationDeletion(organizationId).orThrow();
```

The request needs `organization.delete`, and disables the tenant through
the access contract: the organization's `disabled_at` with the managed
organizations module, or the `sql.modules.access.disabled.tenant` column.
Disabled tenants get no permissions and no membership claims, so members
lose access at once. `grace` takes an interval or a `Temporal.Duration`
and defaults to the `grace` option.

With `sql.modules.data-lifecycle.permissions.deletePlatform` set to a
platform permission (`is_platform`), platform staff can request and cancel a
deletion for any organization without the service role, such as from an
admin console.

The requester or an owner can cancel until the purge runs; cancelling
enables the tenant again, unless it was disabled before the request.
`organizationDeletion(organizationId)` returns the pending deletion to the
organization's members, for a banner with the purge date.

## Purging [#purging]

The purger runs the deletions past their grace period with a service-role
client, from a cron route or a jobs queue:

```ts title="app/api/cron/purge/route.ts"
import {
  createOrganizationPurger,
  sqlTransport,
} from "better-supabase/blocks/data-lifecycle";

const purger = createOrganizationPurger({
  transport: sqlTransport(postgres.admin),
  storage: supabaseAdmin.storage,
  buckets: ["attachments"],
  billing,
});

await purger.purgeDue({ limit: 20 }).orThrow();
```

For each organization it checks that the deletion is due, cancels the
Stripe subscription (`billing.cancelSubscription`, when you pass
`billing`), revokes the organization's credentials, removes every object
under the organization's prefix in `buckets`, then calls
`purge_organization`. That function runs your
`public.on_organization_purge(tenant)` hook when it exists, deletes the
tenant's rows from each purged table (your tables first), deletes the
tenant's own row last and writes `organization.purged`. It returns the
deleted row count per table. A table whose rows another table still
references through a restricting foreign key, or whose delete makes an
`on delete set null` break a check on another table, waits for a later pass,
so the order of the tables doesn't matter and your foreign keys can stay;
when no pass makes progress, the purge stops with `ORGANIZATION_PURGE_BLOCKED`
and names the tables.

The tenant row is the organizations module's (managed or adopted), or the
row of the table `sql.modules.access.disabled.tenant` names, or the one in
`options.tenantRow` (`"schema.table.column"`, `false` to keep it), so an
adopted tenant table needs no `on_organization_purge` hook to go.

A bucket name in `buckets` clears `{organizationId}/`. For buckets laid out
another way, pass `{ bucket, path }` with a template, or a function that
returns the prefixes. Each prefix must contain the organization id, so a
purge never clears another tenant's objects:

```ts
buckets: [
  "attachments",
  { bucket: "files", path: "orgs/{organizationId}/files" },
  { bucket: "media", path: (id) => [`public/${id}`, `private/${id}`] },
],
```

### Credentials [#credentials]

Pass `credentials` with your [credential provider](/docs/extending/credentials)
and the purger revokes the `credential_ref` of every row the purge deletes,
such as AI provider keys, connector servers, connector grants (for the
grant's user) and workflow credentials, before it deletes the rows:

```ts
const purger = createOrganizationPurger({
  transport: sqlTransport(postgres.admin),
  credentials: vaultCredentials({
    transport: credentialsTransport(postgres.admin),
  }),
});

const [purged] = await purger.purgeDue().orThrow();
purged?.credentials.revoked; // 3
purged?.credentials.unrevoked; // [{ table, column, ref, subject, reason }]
```

`better_supabase.organization_credential_refs(tenant)` lists the refs for
the purger and refuses a deletion that isn't due. The purger revokes a ref
only when it carries the organization in its `tenant`, the same check that
guards credential writes, so a purge never revokes another tenant's or the
app's own secret. The refs it leaves come back in `credentials.unrevoked`
with a reason, for you to revoke or keep:

| Reason          | The ref                                                        |
| --------------- | -------------------------------------------------------------- |
| `foreign`       | carries no tenant or another tenant                            |
| `no_provider`   | is in the tenant, but the purger has no `credentials` provider |
| `not_revocable` | belongs to a provider that can't revoke (`env`)                |

A failed revoke stops the purge before it deletes anything, so running it
again retries. Chat installations keep their rows in the purge (`purge:
false`): call `uninstallTenant` from the
[Chat SDK channels](/docs/chat-sdk/channels) before the purge, which revokes
their credentials.

## Anonymizing [#anonymizing]

Some records must lose their personal data a while after a process ends
without being deleted, such as a candidate's details 180 days after the
application closed. `options.anonymize` lists those rules, one per table:

```ts title="better-supabase.config.ts"
"data-lifecycle": {
  options: {
    anonymize: [
      {
        table: "public.candidates",
        after: "180 days",
        from: "process_ended_at",
        unless: "{row}.talent_pool_consent_until > now()",
        set: {
          first_name: "Anonymized",
          last_name: null,
          email: null,
          phone_hash: { sql: "md5({row}.phone)" },
        },
        markedBy: "anonymized_at",
      },
    ],
  },
},
```

| Field      | Meaning                                                                                                 |
| ---------- | ------------------------------------------------------------------------------------------------------- |
| `table`    | `table` or `schema.table`                                                                               |
| `after`    | an interval: a row is due once `from` is older than this                                                |
| `from`     | the timestamp column the interval counts from; a row with no value is never due                         |
| `unless`   | a condition on the row `{row}` that keeps a due row as it is while it holds                             |
| `set`      | the new value per column: a string, number, boolean, `null`, or `{ sql }` with an expression on `{row}` |
| `markedBy` | a nullable timestamp column the rule sets, so each row is anonymized once                               |

`better_supabase.anonymize_due(max_rows)` applies every rule to at most
`max_rows` (1000) due rows each and returns the rows changed per table. The
service role calls it through the purger, and `createOrganizationPurger({
anonymize: true })` also runs it in `job`, after the due purges:

```ts
const anonymized = await purger.anonymizeDue({ limit: 500 }).orThrow();
// { "public.candidates": 12 }
```

The rule changes columns only. Files in Storage that belong to the row,
such as a CV, stay where they are; remove them from an `after update`
trigger on `markedBy` or in your own job.

## Events [#events]

With the outbox installed, the module writes these events with the tenant as
the partition key, except `organization.purged`, which has none because the
tenant is gone by then (its id stays in `organizationId`):

| Event                             | When                               | Data                                                             |
| --------------------------------- | ---------------------------------- | ---------------------------------------------------------------- |
| `data_export.requested`           | an export is requested             | `exportId`, `subject`, `organizationId`, `userId`, `requestedBy` |
| `data_export.completed`           | its files are written              | the same, with `files` and `expiresAt`                           |
| `data_export.failed`              | the exporter failed                | the same, with `error`                                           |
| `organization.deletion_requested` | a deletion is requested            | `organizationId`, `userId`, `purgeAfter`                         |
| `organization.deletion_cancelled` | it is cancelled                    | the same                                                         |
| `organization.purged`             | the purge deleted the organization | `organizationId`                                                 |

# Entitlements

> Stripe entitlements per tenant in the access token, in RLS, and fresh again after a plan change.

Source: https://bettersupabase.com/docs/blocks/entitlements

[Stripe Entitlements](https://docs.stripe.com/billing/entitlements) attach
features (`exports`, `sso`) to products; a customer's active entitlements
follow their subscriptions. The
[Stripe Sync Engine](https://github.com/supabase/stripe-sync-engine)
mirrors them into `stripe.active_entitlements`. The `entitlements` SQL module
reads that table per tenant, so the UI can hide what a plan doesn't include
and RLS can refuse it.

```bash
better-supabase sql add entitlements   # adds tenant as well
```

## Configuration [#configuration]

Each tenant needs its Stripe customer id somewhere. With the
[`billing` module](/docs/blocks/billing) installed alongside, the module reads
`better_supabase.billing_customers`, which checkout fills. Otherwise, with the
managed [`organizations` module](/docs/blocks/organizations), it adds
`better_supabase.organizations.stripe_customer_id` (unique) and reads it.
Without either, point the module at your own column; `sql add` stops without one:

```ts title="better-supabase.config.ts"
export default defineConfig({
  entitlements: {
    customer: "billing_customers.stripe_customer_id",
    key: "organization_id", // the tenant id column, defaults to id
  },
});
```

`sql add` writes `tenant_stripe_customer(tenant)` for that column, so the
file fails to apply if the column doesn't exist, rather than failing on every
request later. The module reads the tables of Stripe Sync Engine 0.48.5
(`SPEC_PINS.stripeSyncEngine`). Before the engine is installed, every tenant
has no entitlements.

### Other sources [#other-sources]

Apps that keep their own billing mirror don't need the Sync Engine.
`entitlements.source` picks where the features come from; the checks,
the claim and `hasEntitlement` stay the same.

A plan catalog in your tables: the tenant's subscription names a plan, and a
features table lists what each plan includes. With `status`, only
subscriptions in `activeStatuses` (`active` and `trialing` by default) count;
with `included`, only rows where it is true.

```ts title="better-supabase.config.ts"
export default defineConfig({
  entitlements: {
    source: {
      plans: {
        subscriptions: {
          table: "public.subscriptions",
          tenant: "organization_id",
          plan: "plan_key",
          status: "status",
        },
        features: {
          table: "public.plan_features",
          plan: "plan_key",
          feature: "feature_key",
          included: "included",
        },
      },
    },
  },
});
```

With a plan catalog, the module also writes
`better_supabase.tenant_plans(tenant)`, the tenant's active plan keys, which
[usage quotas](/docs/blocks/usage) match by plan key.

### Feature values [#feature-values]

A feature can carry a value, such as how many days of file history a plan
keeps (30, 90 or 365) or a support level, so the tier needs no parsing from
the feature key. Name the column in `features.value` (a number, text or
`jsonb` column):

```ts
features: {
  table: "public.plan_features",
  plan: "plan_key",
  feature: "feature_key",
  value: "value",
},
```

`better_supabase.tenant_entitlement_value(tenant, key)` returns it as
`jsonb`: the largest number when several active plans set the feature,
otherwise the first plan's value, `true` for a feature whose value is null,
and null when the tenant lacks the feature. `entitlement_value(tenant, key)`
returns the same to a member of the tenant (null to anyone else):

```sql
select (better_supabase.tenant_entitlement_value(organization_id, 'file_history_days') #>> '{}')::integer;
```

The Sync Engine source has no values, since Stripe entitlements are on or
off: its `tenant_entitlement_value` returns `true` or null. For limits that
are counted, such as API calls per month, use
[usage quotas](/docs/blocks/usage) instead; a value is for a setting the
plan fixes.

`source: "custom"` writes everything except
`better_supabase.tenant_entitlements(tenant)`, which you write yourself and
which returns the tenant's feature keys as `text[]`, such as plan features
plus per-tenant overrides, and
`better_supabase.tenant_entitlement_value(tenant, key)`, which returns a
feature's value as `jsonb` for `entitlement_value`. Neither source needs `customer`, and
`entitlementMembers` (the Stripe event helper below) applies to the Sync
Engine only.

## SQL [#sql]

| Function                                | Grants                                | Returns                                                                      |
| --------------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------- |
| `tenant_entitlements(tenant)`           | `service_role`, `supabase_auth_admin` | Sorted lookup keys, `{}` without a customer                                  |
| `has_entitlement(tenant, key)`          | `authenticated`                       | `true` when the caller is a member of `tenant` and it has `key`              |
| `tenant_entitlement_value(tenant, key)` | `service_role`                        | The feature's value as `jsonb`, `true` without one, null without the feature |
| `entitlement_value(tenant, key)`        | `authenticated`                       | The same for a member of `tenant`, null for anyone else                      |
| `tenant_ids_with_entitlement(key)`      | `authenticated`                       | The caller's tenants that have `key`, for one set check per query            |
| `feature_claims(user_id)`               | `service_role`, `supabase_auth_admin` | The `features` claim for the user: lookup keys per tenant                    |
| `entitlement_members(customer)`         | `service_role`                        | Members of the tenants billed to `customer`                                  |

RLS reads the live table, so a downgrade takes effect on the next query:

```sql
create policy exports_need_plan on public.exports as restrictive
  for insert to authenticated
  with check ((select better_supabase.has_entitlement(organization_id, 'exports')));
```

On a read policy over many rows, compare against the set instead, so Postgres
evaluates the entitlements once per query rather than once per row:

```sql
create policy exports_read on public.exports for select to authenticated
  using (organization_id in (select better_supabase.tenant_ids_with_entitlement('exports')));
```

## In the access token [#in-the-access-token]

`feature_claims` builds the `features` claim: the plan's lookup keys per
tenant the user is a member of. Plan features stay out of `memberships`, whose
entries follow the
[claims contract](/docs/frameworks/next-cache-components#who-owns-what)
(`scope`, `id`, `roles`); an `entitlements` field on a membership means seats,
not plan features.

```json
{
  "memberships": [{ "scope": "tenant", "id": "4f1c…", "roles": ["admin"] }],
  "features": { "4f1c…": ["exports", "sso"] }
}
```

Call it from your custom access token hook. The `tenant` module's
`membership_claims` fills `memberships`:

```sql
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
set search_path = ''
as $$
declare
  uid uuid := (event ->> 'user_id')::uuid;
begin
  event := jsonb_set(event, '{claims,memberships}', better_supabase.membership_claims(uid));
  return jsonb_set(event, '{claims,features}', better_supabase.feature_claims(uid));
end
$$;

grant execute on function public.custom_access_token_hook(jsonb) to supabase_auth_admin;
```

The claim name comes from `claims.features` in the config (`features` by
default). With an authorization provider's hook, see
[below](#with-an-authorization-provider).

Declare the shape in `betterSupabase.claims()` to type it, with a `picklist` for the keys you
sell:

```ts title="src/lib/supabase/index.ts"
const Claims = v.looseObject({
  features: v.optional(
    v.record(v.string(), v.array(v.picklist(["exports", "sso", "audit"]))),
    {},
  ),
});

export const betterSupabase = defineSupabase(schema).claims(Claims);
```

Every key travels in every request, so keep the list to lookup keys.
[`doctor --as <user id>`](/docs/cli/doctor#bs405) warns when the hook's
claims pass 2 KB. A provider's hook budget (`tokenHook.budget`) covers only the
claims it lists, so `features` is usually outside it.

`entitlements.claim` controls the claim's size and shape:

```ts title="better-supabase.config.ts"
export default defineConfig({
  entitlements: {
    claim: { maxTenants: 20, keys: { exports: "e", sso: "s", audit: "a" } },
  },
});
```

| Setting          | What the claim holds                                                 |
| ---------------- | -------------------------------------------------------------------- |
| unset            | every tenant with entitlements, with their keys                      |
| `false`          | `{}`; check entitlements in SQL with `has_entitlement` only          |
| `{ maxTenants }` | at most that many tenants, the lowest ids first                      |
| `{ keys }`       | the short code for each key, and the key itself for keys without one |

`hasEntitlement` reads the short codes when you pass the same map:
`hasEntitlement(session, tenantId, "exports", { claim: "features", keys })`.
A tenant left out by `maxTenants` reads as not granted, so pair it with
`has_entitlement` in RLS and server checks. Run `better-supabase sql sync`
after changing it.

## In components [#in-components]

`hasEntitlement(session, tenantId, key)` reads the `features` claim, from
`better-supabase/blocks/entitlements`. Pass
the claim name as a fourth argument when `claims.features` renames it. With a
typed claims schema, `key` only accepts its lookup keys:

```tsx
import { hasEntitlement } from "better-supabase/blocks/entitlements";

const session = await bs.session();
if (!hasEntitlement(session, organizationId, "exports"))
  return <UpgradePrompt />;
```

This is UX: the policy above still decides.

## With an authorization provider [#with-an-authorization-provider]

With an [authorization provider](/docs/extending/authorization-providers) in
the config, the module reads memberships through the provider's `memberIds`
and `memberIdsFor` functions instead of the `tenant` module, and
`sql add entitlements` no longer adds `tenant`:

| Function                        | Membership check                                                                  |
| ------------------------------- | --------------------------------------------------------------------------------- |
| `has_entitlement(tenant, key)`  | `tenant in (select <memberIds>)`, as the signed-in user                           |
| `feature_claims(user_id)`       | `<memberIdsFor>` for `user_id`, from the provider's hook as `supabase_auth_admin` |
| `entitlement_members(customer)` | the provider's `memberships` tables for the tenant scope                          |

The scope is the provider's `tenantScope`, and the tenant argument of
`has_entitlement`, `tenant_entitlements` and `tenant_stripe_customer` takes
that scope's `idType`. When the provider lacks `memberIds` or
`memberIdsFor`, or the scope has no `idType`, `sql add entitlements` stops and
doctor reports [BS408](/docs/cli/doctor#bs408). BS408 also reports a tenant
scope without a `memberships` entry, which `entitlement_members` needs.

`entitlements.memberships: "tenant"` keeps the `tenant` module's
`better_supabase.memberships` even with a provider.

`features` is not a claim a provider usually owns, so `sql add entitlements`
works without `--force`. For `hasEntitlement(session, ...)`, the provider's
hook must write the `features` claim from `better_supabase.feature_claims`.
List it in `tokenHook.registeredClaims` and doctor's BS407 accepts the second
writer. Grant `supabase_auth_admin` execute on the `memberIdsFor` function,
which `feature_claims` calls; doctor (BS408) reports a database where it
can't.

The module's `feature_claims` reads Stripe's `stripe.active_entitlements`, so
an app whose plan features live in its own table keeps its own
`feature_claims` and names that one in the hook instead.

## Keeping claims fresh [#keeping-claims-fresh]

A token keeps the entitlements it was issued with until it is refreshed.
Two things close the gap:

1. **After checkout,** call `supabase.auth.refreshSession()` in the browser
   once the success page loads. The new token runs the hook again.
2. **On plan changes Stripe initiates** (renewal failures, cancellations at
   period end), handle `entitlements.active_entitlement_summary.updated` in
   the [webhook inbox](/docs/blocks/jobs#webhook-inbox) and drop the members'
   cached sessions:

```ts title="src/app/api/stripe/entitlements/route.ts"
import { dbError, err, ok } from "better-supabase";
import {
  ENTITLEMENTS_UPDATED,
  entitlementMembers,
} from "better-supabase/blocks/entitlements";
import { createWebhookInbox } from "better-supabase/blocks/jobs";

const inbox = createWebhookInbox(postgres.admin, {
  source: "stripe-entitlements",
  verify: async (request, body) => {
    try {
      const event = await stripe.webhooks.constructEventAsync(
        body,
        request.headers.get("stripe-signature") ?? "",
        process.env.STRIPE_ENTITLEMENTS_SECRET!,
      );
      return ok({ id: event.id, payload: event });
    } catch (cause) {
      return err(dbError("unauthorized", String(cause)));
    }
  },
});

export const POST = (request: Request) => inbox.receive(request);
```

```ts title="src/app/api/cron/inbox/route.ts"
export const GET = async () => {
  const result = await inbox.process(async (message) => {
    if (message.type !== ENTITLEMENTS_UPDATED) return;
    const members = await entitlementMembers(
      postgres.admin,
      message.payload,
    ).orThrow();
    for (const userId of members) bs.invalidateSession(userId);
  });
  return Response.json(result);
};
```

`invalidateSession` drops `bs.cached()` entries, so the next render asks
for the session again. The token itself only changes on refresh, which the
proxy does when it expires. Send the event to its own endpoint: the Stripe
Sync Engine keeps its webhook for syncing the tables.

# Feature flags

> Feature flags per tenant and per user, with targeting rules, overrides and percentage rollouts, evaluated the same way in RLS and in an OpenFeature provider.

Source: https://bettersupabase.com/docs/blocks/flags

The `flags` block stores feature flags in Postgres and evaluates them in two
places: `flag_enabled()` in policies and functions, and an OpenFeature
provider in the app. Both use the same rules and the same SHA-256 bucketing,
so a user who sees a feature in the UI also passes the policy behind it.

Flags decide what a tenant or user sees while a feature rolls out. What a
tenant pays for stays in [entitlements](/docs/blocks/entitlements); a flag
rule can still target plans.

```bash
better-supabase sql add flags   # adds tenant as well
```

| Table            | Holds                                                                                                     |
| ---------------- | --------------------------------------------------------------------------------------------------------- |
| `flags`          | `key`, `type`, `variants`, `default_variant`, `enabled`, `rules`, `rollout_percentage`, `rollout_variant` |
| `flag_overrides` | A variant for one `organization_id` or one `user_id`                                                      |

Only the service role reads and writes both tables. Manage flags from a
migration, the Studio or an admin route that runs as the service role.

## Defining flags [#defining-flags]

`variants` maps a variant name to its value. A boolean flag has the variants
`on` (`true`) and `off` (`false`) by default:

```sql
insert into better_supabase.flags (key, rules, rollout_percentage, rollout_variant) values
  ('new_editor', '[{"variant": "on", "plans": ["pro"]}, {"variant": "on", "roles": ["admin"]}]', 10, 'on');

insert into better_supabase.flags (key, type, variants, default_variant) values
  ('checkout_theme', 'string', '{"classic": "classic", "compact": "compact"}', 'classic');

insert into better_supabase.flag_overrides (flag_key, organization_id, variant)
  values ('new_editor', '8d1c...', 'on');
```

A flag resolves in this order:

1. A disabled flag (`enabled = false`) returns its default variant.
2. A user override, then a tenant override.
3. The first rule whose lists all contain the caller. A rule lists any of
   `tenants`, `users`, `roles` (the caller's role in the tenant) and `plans`
   (entitlement lookup keys, one match is enough).
4. The rollout: the first 32 bits of SHA-256 of `flag.target`, modulo 10000,
   below `rollout_percentage * 100` returns `rollout_variant`. The target is
   the user id, or the tenant id when there is no user.
5. The default variant.

The reason in each result follows OpenFeature: `DISABLED`,
`TARGETING_MATCH`, `SPLIT` or `DEFAULT`.

### Managing flags from an admin page [#managing-flags-from-an-admin-page]

`createFlagAdmin` manages flags for platform staff with `flags.manage` (the
module's `manage` permission, checked with `is_platform()` when the
[access](/docs/blocks/access) module is installed) or the service role. It
calls `list_flags`, `save_flag`, `delete_flag` and `set_flag_override`,
which are granted to `authenticated`, so an app that only reaches the
database through the Data API manages flags with `sql.modules.flags.api`
and `rpcTransport`:

```ts
import { createFlagAdmin, rpcTransport } from "better-supabase/blocks/flags";

const admin = createFlagAdmin({
  transport: rpcTransport(supabase, { schema: "api" }),
});

await admin.save("new_editor", { rolloutPercentage: 25, rolloutVariant: "on" });
await admin.override("new_editor", { organizationId }, "on");
await admin.override("new_editor", { userId }, null); // removes it
const flags = await admin.list().orThrow();
```

`save` creates the flag or updates the fields you pass and keeps the rest.
Anyone else gets `FLAGS_FORBIDDEN`.

## In policies [#in-policies]

`flag_enabled(key, tenant)` is true when the flag resolves to `true` for the
caller in that tenant:

```sql
create policy "drafts_insert" on public.drafts for insert to authenticated
  with check (better_supabase.flag_enabled('new_editor', organization_id));
```

`flag_enabled` evaluates the flag for each row it checks. For a policy that
reads many rows, `tenant_ids_with_flag(key)` returns the caller's tenants
where the flag is on, and Postgres evaluates it once per statement:

```sql
create policy "drafts_read" on public.drafts for select to authenticated
  using (organization_id in (select better_supabase.tenant_ids_with_flag('new_editor')));
```

`flag_evaluation(key, tenant, user)` returns `{ value, variant, reason }` for
any flag type, or null for an unknown flag. It is granted to the service role.

## With OpenFeature [#with-openfeature]

`createFlagsProvider` returns an object shaped like an OpenFeature server
`Provider`. It loads every flag with `flag_definitions()` (service role only)
and caches them for `ttl` milliseconds (default 30000). This package never
imports OpenFeature; pass its `ErrorCode` enum so the types match:

```ts title="lib/flags.ts"
import { ErrorCode, OpenFeature } from "@openfeature/server-sdk";
import {
  createFlagsProvider,
  sqlTransport,
} from "better-supabase/blocks/flags";

const provider = createFlagsProvider({
  transport: sqlTransport(postgres.admin),
  errorCodes: ErrorCode,
});
await OpenFeature.setProviderAndWait(provider);
export const flags = OpenFeature.getClient();
```

`flagContext(ctx)` builds the evaluation context from verified claims: the
user id as `targetingKey`, the tenant (`tenant_id`, then
`app_metadata.tenant_id`), its plan features from the `features` claim and
the caller's role from the `memberships` claim:

```ts
import { flagContext } from "better-supabase/blocks/flags";

const details = await flags.getBooleanDetails(
  "new_editor",
  false,
  flagContext({ jwtClaims: ctx.jwtClaims }),
);
```

The `memberships` claim can have either shape: an object of tenant id to
role, as the `tenant` module's hook writes, or a list of
`{ scope, id, roles }` entries, as an authorization provider's hook may
write. For a list,
`flagContext` reads the entry whose `id` is the tenant and that has no
`within` (the root scope), or the entry of `membershipScope` when you set it.
A caller with several roles gets the first as `role` and all of them as
`roles`, and a rule's `roles` list matches any of them. For a claim of
another shape, pass `roles: (claims, tenant) => ...`.

```ts
flagContext({ jwtClaims: ctx.jwtClaims }, { membershipScope: "organization" });
```

Without the OpenFeature SDK, `createFlagClient({ transport, errorCodes })`
returns the four `get*Details` methods that `withOpenFeature` calls (see
[middleware](/docs/auth/middleware#feature-flags)). `evaluateFlag` and
`flagBucket` are exported for tests and for flags defined in code
(`createFlagsProvider({ definitions })`).

## Reference [#reference]

| Export                      | Does                                                                             |
| --------------------------- | -------------------------------------------------------------------------------- |
| `createFlagsProvider(opts)` | An OpenFeature-shaped provider over `flag_definitions()`, or fixed `definitions` |
| `createFlagClient(source)`  | `getBooleanDetails`, `getStringDetails`, `getNumberDetails`, `getObjectDetails`  |
| `flagContext(ctx, opts?)`   | The evaluation context from `jwtClaims`; claim names are options                 |
| `evaluateFlag(flag, ctx)`   | `{ value, variant, reason }`, as `flag_evaluation()` computes it                 |
| `flagBucket(key, target)`   | The rollout bucket, 0 to 9999                                                    |
| `createFlagAdmin(opts)`     | `list`, `save`, `remove` and `override` for platform staff or the service role   |

The provider follows the OpenFeature specification pinned in
`SPEC_PINS.openfeature` (see [standards](/docs/standards)).

# Inbox

> A shared inbox for support conversations from an in-app widget, Slack, WhatsApp, SMS and other channels, with assignment, internal notes, read receipts, bot handoff and realtime updates.

Source: https://bettersupabase.com/docs/blocks/inbox

The `inbox` [SQL module](/docs/blocks/sql) stores inboxes, contacts,
conversations and messages per organization. `createInbox` from
`better-supabase/blocks/inbox` reads and writes them, and the hooks in
`better-supabase/blocks/inbox/react` keep a list, a thread and an in-app
widget current over Supabase Realtime. Channel messages reach the same
tables through the [Chat SDK adapters](/docs/chat-sdk).

```bash
pnpm better-supabase sql add inbox
```

The module needs the [access contract](/docs/blocks/access). With the
[jobs](/docs/blocks/jobs) module installed, an inbound message to a bot
conversation queues a bot job and an outbound message on a channel queues a
delivery job; without it, nothing is queued and the app delivers the
messages itself. With [notifications](/docs/blocks/notifications)
installed, assignees and mentioned staff get a notification, and with the
[outbox](/docs/blocks/outbox) every change emits a block event.

## Permissions [#permissions]

Staff need a tenant permission for each action. The roles in your access
contract grant them like any other permission.

| Permission     | Allows                                                                      |
| -------------- | --------------------------------------------------------------------------- |
| `inbox.read`   | Listing conversations, reading messages and notes, seeing a contact's reads |
| `inbox.reply`  | Sending replies and internal notes, reacting, typing pings                  |
| `inbox.assign` | Assigning, changing the status, snoozing and handing off to staff           |
| `inbox.manage` | Creating inboxes and teams, members, templates and `purgeContact`           |

A contact who is a signed-in user reads only their own conversations and
never sees internal notes. Inboxes with `settings.widget = true` let such a
visitor open a conversation; other inboxes take new conversations only from
staff and the service.

## Creating the client [#creating-the-client]

`createInbox` takes a [block transport](/docs/blocks#transports). Use the
caller's client in server actions so RLS and the permission checks apply,
and a service transport for bots and channel webhooks:

```ts title="lib/blocks.ts"
import "server-only";

import { createInbox, sqlTransport } from "better-supabase/blocks/inbox";

export const inboxFor = (claims: Record<string, unknown>) =>
  createInbox({ transport: sqlTransport(postgres.asUser(claims)) });

export const serviceInbox = createInbox({
  transport: sqlTransport(postgres.asService()),
});
```

`rpcTransport(supabase)` calls the same functions over the Data API when
the block schema is exposed. Every method returns a `Result`, so database
errors come back as a `DbError` instead of an exception.

## Inboxes and contacts [#inboxes-and-contacts]

```ts
const help = await inbox.inboxes
  .create({
    tenant: organizationId,
    name: "Help",
    channel: "widget",
    botMode: "human",
    settings: { widget: true },
  })
  .orThrow();

await inbox.inboxes.setMember(help.id, agentId, "agent").orThrow();
const contact = await inbox.contacts
  .upsert(organizationId, { email: "ada@example.com", name: "Ada" })
  .orThrow();
```

`contacts.upsert` finds a contact by id, channel identity, user or email
and fills in the fields it lacks, so a WhatsApp number and a signed-in user
with the same email end up as one contact.

## Conversations and messages [#conversations-and-messages]

```ts
const conversation = await inbox.conversations
  .open(help.id, { contact: contact.id, subject: "Billing", message: "Hi" })
  .orThrow();

await inbox.messages
  .send(conversation.id, { body: "How can we help?" })
  .orThrow();
await inbox.messages
  .note(conversation.id, "Refund approved", { mentions: [leadId] })
  .orThrow();
await inbox.conversations
  .assign(conversation.id, { assigneeId: agentId })
  .orThrow();
await inbox.conversations.resolve(conversation.id).orThrow();
```

| Method                                                             | Does                                                                         |
| ------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| `conversations.list(tenant, filter)`                               | Conversations by status, inbox, assignee, team, contact or search text       |
| `conversations.get(id)`                                            | One conversation with `lastReadAt` for the caller and `contactReadAt`        |
| `conversations.assign`, `setStatus`, `snooze`, `resolve`, `reopen` | Routing and status; a contact's new message reopens a resolved conversation  |
| `conversations.handoff(id, reason)`, `setBotMode`                  | Moves a bot conversation to staff and tells them                             |
| `conversations.markRead`, `typing`, `events`, `counts`             | Read state, typing pings, the conversation's history, open and unread counts |
| `messages.send`, `note`, `list`, `get`, `edit`, `remove`, `react`  | Messages and notes; `remove` leaves a tombstone                              |
| `templates.upsert`, `delete`                                       | Approved channel templates, such as WhatsApp's                               |
| `purgeContact(contactId)`                                          | Erases a contact and its conversations; returns attachment paths to delete   |

`contactReadAt` is the last time the contact read the conversation, only
for staff, so a thread can show a read receipt. A message body is limited
to `maxBodyLength` characters (20000 by default), and attachments live in
the private `inbox-files` bucket under the conversation's path, readable by
the staff and the contact of that conversation.

`purgeContact` deletes the rows; remove the paths it returns from Storage
afterwards, since a SQL function can't delete Storage objects.

## Bots and outgoing messages [#bots-and-outgoing-messages]

Each inbox and conversation has a `botMode`: `bot`, `human` (staff answer)
or `paused` (nobody answers, for example while a contact is blocked). When
a contact writes in `bot` mode the module queues a job on `inbox_bot`, and
a staff reply on a channel inbox queues one on `inbox_outbound`. The job
handlers in [Chat SDK adapters](/docs/chat-sdk) run the bot and post the
reply on its channel. A staff reply or `handoff` switches the conversation
to `human`, so the bot stops answering.

## Realtime [#realtime]

Changes broadcast without row data on private topics: `inbox:<conversationId>`
for a thread (`message`, `typing`, `read` and `conversation`) and
`inbox:org:<organizationId>` for the lists. The hooks reload through your
server action when a broadcast arrives.

```tsx title="components/conversation-list.tsx"
"use client";

import { useInbox } from "better-supabase/blocks/inbox/react";

import { loadConversations } from "./inbox-actions";

export function ConversationList({
  organizationId,
}: {
  organizationId: string;
}) {
  const { items } = useInbox({
    organizationId,
    load: () => loadConversations({ status: "open" }),
  });
  return (
    <ul>
      {items?.map((row) => (
        <li key={row.id}>{row.subject}</li>
      ))}
    </ul>
  );
}
```

| Hook                                        | Returns                                                          |
| ------------------------------------------- | ---------------------------------------------------------------- |
| `useInbox({ organizationId, load })`        | `items`, `status`, `error` and `refresh` for a conversation list |
| `useConversation({ conversationId, load })` | The messages as `items`, who is `typing` and a `setTyping`       |
| `useInboxWidget({ open, send, load })`      | An in-app widget: `submit`, `reset` and the open conversation    |

`useInboxWidget` keeps the open conversation id in `localStorage` under
`storageKey`, so mount it only in the browser. Throttle `setTyping`
yourself; a ping counts for `typingMs` (5000 by default).

## Block events [#block-events]

With the outbox installed the module emits `inbox_message.received`,
`inbox_conversation.opened`, `inbox_conversation.assigned`,
`inbox_conversation.resolved` and `inbox_conversation.reopened`, each with
the conversation, organization, inbox, contact, assignee and status.

## Options [#options]

| Option                                    | Default          | Meaning                                         |
| ----------------------------------------- | ---------------- | ----------------------------------------------- |
| `sql.modules.inbox.options.topic`         | `inbox`          | The Realtime topic prefix; pass it to the hooks |
| `sql.modules.inbox.options.bucket`        | `inbox-files`    | The private Storage bucket for attachments      |
| `sql.modules.inbox.options.maxBodyLength` | `20000`          | The longest message body                        |
| `sql.modules.inbox.options.botQueue`      | `inbox_bot`      | The job queue for bot replies                   |
| `sql.modules.inbox.options.outboundQueue` | `inbox_outbound` | The job queue for channel deliveries            |
| `sql.modules.inbox.options.notify`        | `true`           | Notifications for assignments and mentions      |

## Example [#example]

The Next.js example has a staff inbox at `/inbox` with status and assignee
filters, assignment, internal notes, read receipts and typing, and a Help
sheet in the header where a signed-in user opens a conversation with the
`useInboxWidget` hook. The assistant answers in the Help sheet until a
staff member replies. See `apps/examples/nextjs/src/features/inbox` and
[Build a unibox inbox](/docs/build/unibox-inbox).

# Overview

> Feature modules for SaaS apps, each a set of SQL modules in your schema with an optional TypeScript side under better-supabase/blocks.

Source: https://bettersupabase.com/docs/blocks

A block is a feature most SaaS apps build by hand: organizations and
invitations, background jobs, notifications, outgoing webhooks, plan
entitlements. Each block installs one or more [SQL modules](/docs/blocks/sql)
into `supabase/schemas` with `better-supabase sql add`, and blocks with a
TypeScript side import it from `better-supabase/blocks/<name>`. Blocks that are
SQL only have no subpath; you call their functions from policies and RPCs.

```bash
pnpm better-supabase sql add organizations invitations
```

```ts
import { createOrganizations } from "better-supabase/blocks/organizations";
```

To build several blocks with one set of options, and wire the blocks that
use each other, use [`createBlocks`](/docs/blocks/create-blocks) from
`better-supabase/blocks`.

A block can be extended without forking it: add your own columns and type
them with a Standard Schema, steer its methods with hooks in TypeScript or
SQL, wrap its transport with middleware and add methods. See
[Extending blocks](/docs/extending/blocks).

List the modules to keep in sync in `sql.modules` in `better-supabase.config.ts`,
keyed by module name, with each module's settings as the value. The primitives that every app
uses, such as [list queries](/docs/platform/list),
[storage](/docs/platform/storage) and [realtime topics](/docs/platform/realtime),
are not blocks and keep their top-level subpaths.

## Connections [#connections]

A block's TypeScript side runs its SQL functions through a connection you
pass in. It never opens a pool of its own.

| Connection                                                              | What it runs as                             | Blocks                                                        |
| ----------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------- |
| `ctx.postgresAdmin` from `withPostgresAdminClient` (`@supabase/server`) | The connection-string role, bypassing RLS   | jobs, idempotency, webhook inbox, outbox, audit, webhooks out |
| `ctx.postgres` from `withPostgresClient`                                | The caller, with RLS                        | organizations, notifications (through `sqlTransport`)         |
| `createPostgres()` from `better-supabase/postgres`                      | `admin`, or a user through `asUser(claims)` | every block                                                   |
| `rpcTransport(supabase)` over PostgREST                                 | The client's session, or the secret key     | organizations, notifications, webhooks out                    |

Every function that takes a SQL client accepts `@supabase/server`'s Postgres
clients as they are, so an app that already composes `withPostgresClient` and
`withPostgresAdminClient` keeps one pool and one connection string
(`SUPABASE_DB_URL` by default, or `connectionString`). `withBlock` from
`better-supabase/server` builds a block on the pipeline context; see
[middleware](/docs/auth/middleware).

`@supabase/server` runs each query in its own transaction. A block call is
one SQL function, so that changes nothing for the blocks, but
`outbox.emit()` then commits on its own instead of with the writes before it.
Emit from SQL (`emit_event` inside the function that writes) when the event
must commit with the write.

A SQL connection needs TCP: Node, Deno, Bun and the Supabase Edge runtime have
it, Cloudflare Workers and other isolates without sockets do not. There, use
`rpcTransport` for the blocks that call only SQL functions (organizations,
notifications, outgoing webhooks, flags, announcements and the rest of the
blocks that take a `transport`, and `createOutbox`, which takes a transport in
place of the SQL client), and the `pgmq_public` RPCs for jobs. The
TypeScript helpers of the idempotency keys, the webhook inbox, incoming
webhooks, route rate limits and `exportAuditLog` still need a SQL connection,
but every function a module grants, the service-role ones of `jobs`,
`idempotency`, `webhook-inbox` and `access` included, gets an entry point in
the API schema, so a server can call them over the Data API. Give
the module an API schema (`sql.modules.<module>.api`, see
[SQL modules](/docs/blocks/sql#calling-a-module-over-the-data-api)), expose
that schema, and pass it as `rpcTransport(supabase, { schema: "api" })`. Never
expose the module schema itself.

## Blocks [#blocks]

| Block                                                       | Subpath                                                                                                                            | SQL modules                            |
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| [Access contract](/docs/blocks/access)                      | none                                                                                                                               | `access`, `tenant`                     |
| [Organizations and invitations](/docs/blocks/organizations) | `better-supabase/blocks/organizations`                                                                                             | `organizations`, `invitations`         |
| [Profiles](/docs/blocks/profiles)                           | `better-supabase/blocks/profiles`                                                                                                  | `profiles`                             |
| [Audit log](/docs/blocks/audit)                             | `better-supabase/blocks/audit`                                                                                                     | `audit`                                |
| [Jobs, idempotency and webhook inbox](/docs/blocks/jobs)    | `better-supabase/blocks/jobs`                                                                                                      | `jobs`, `idempotency`, `webhook-inbox` |
| [Outbox](/docs/blocks/outbox)                               | `better-supabase/blocks/outbox`                                                                                                    | `outbox`                               |
| [Durable streams](/docs/blocks/streams)                     | `better-supabase/streams`, `better-supabase/streams/redis`                                                                         | `streams`                              |
| [Workflows](/docs/blocks/workflows)                         | `better-supabase/blocks/workflows`, `better-supabase/blocks/workflows/react`                                                       | `workflows`                            |
| [Workflow SDK](/docs/blocks/workflow-sdk)                   | `better-supabase/workflow-sdk`, `better-supabase/workflow-sdk/world`                                                               | `workflow-sdk-world`                   |
| [Workflow builder](/docs/blocks/workflow-builder)           | `better-supabase/blocks/workflow-builder`, `better-supabase/blocks/workflow-builder/react`, `better-supabase/workflow-sdk/builder` | `workflow-builder`                     |
| [Notifications](/docs/blocks/notifications)                 | `better-supabase/blocks/notifications`, `better-supabase/blocks/notifications/react`                                               | `notifications`                        |
| [Inbox](/docs/blocks/inbox)                                 | `better-supabase/blocks/inbox`, `better-supabase/blocks/inbox/react`, `better-supabase/chat-sdk`                                   | `inbox`, `chat-sdk-state`              |
| [Outgoing webhooks](/docs/blocks/webhooks-out)              | `better-supabase/blocks/webhooks`                                                                                                  | `webhooks-out`                         |
| [Incoming webhooks](/docs/blocks/webhooks-in)               | `better-supabase/blocks/webhooks`                                                                                                  | `webhooks-in`, `webhook-inbox`         |
| [API keys](/docs/blocks/api-keys)                           | `better-supabase/blocks/api-keys`                                                                                                  | `api-keys`                             |
| [Settings](/docs/blocks/settings)                           | `better-supabase/blocks/settings`                                                                                                  | `settings`                             |
| [Usage and quotas](/docs/blocks/usage)                      | `better-supabase/blocks/usage`                                                                                                     | `usage`                                |
| [Billing](/docs/blocks/billing)                             | `better-supabase/blocks/billing`                                                                                                   | `billing`                              |
| [Feature flags](/docs/blocks/flags)                         | `better-supabase/blocks/flags`                                                                                                     | `flags`                                |
| [Comments and activity](/docs/blocks/comments)              | `better-supabase/blocks/comments`                                                                                                  | `comments`                             |
| [Attachments](/docs/blocks/attachments)                     | `better-supabase/blocks/attachments`                                                                                               | `attachments`                          |
| [Data lifecycle](/docs/blocks/data-lifecycle)               | `better-supabase/blocks/data-lifecycle`                                                                                            | `data-lifecycle`                       |
| [SSO and SCIM](/docs/blocks/sso)                            | `better-supabase/blocks/sso`                                                                                                       | `sso`                                  |
| [Onboarding](/docs/blocks/onboarding)                       | `better-supabase/blocks/onboarding`, `better-supabase/blocks/onboarding/react`                                                     | `onboarding`                           |
| [Waitlist and invite codes](/docs/blocks/waitlist)          | `better-supabase/blocks/waitlist`                                                                                                  | `waitlist`                             |
| [Announcements](/docs/blocks/announcements)                 | `better-supabase/blocks/announcements`, `better-supabase/blocks/announcements/react`                                               | `announcements`                        |
| [Entitlements](/docs/blocks/entitlements)                   | `better-supabase/blocks/entitlements`                                                                                              | `entitlements`                         |
| [Vector search](/docs/blocks/vector-search)                 | none (`db.$search` in the core)                                                                                                    | `vector-search`                        |
| [AI chat](/docs/blocks/ai-chat)                             | `better-supabase/blocks/ai-chat`, `better-supabase/blocks/ai-chat/react`                                                           | `ai-chat`                              |
| [AI files](/docs/blocks/ai-files)                           | `better-supabase/blocks/ai-files`                                                                                                  | `ai-files`                             |
| [Knowledge](/docs/blocks/knowledge)                         | `better-supabase/blocks/knowledge`                                                                                                 | `knowledge`                            |
| [Memory](/docs/blocks/memory)                               | `better-supabase/blocks/memory`                                                                                                    | `memory`                               |
| [Agents](/docs/blocks/agents)                               | `better-supabase/blocks/agents`                                                                                                    | `agents`                               |
| [Connectors](/docs/blocks/connectors)                       | `better-supabase/blocks/connectors`                                                                                                | `connectors`                           |
| [AI tasks](/docs/blocks/ai-tasks)                           | `better-supabase/blocks/ai-tasks`                                                                                                  | `ai-tasks`                             |
| [Push notifications](/docs/blocks/push)                     | `better-supabase/blocks/push`                                                                                                      | `push`                                 |
| [AI cache](/docs/blocks/ai-cache)                           | `better-supabase/blocks/ai-cache`                                                                                                  | `ai-cache`                             |
| [AI providers](/docs/blocks/ai-providers)                   | `better-supabase/blocks/ai-providers`                                                                                              | `ai-providers`                         |
| [Credentials](/docs/extending/credentials)                  | `better-supabase/credentials`, `better-supabase/vercel-connect`                                                                    | `credentials`                          |

## SDK adapters [#sdk-adapters]

An adapter connects a third-party SDK to the blocks above. It owns no tables
and no SQL modules of its own: install the modules of the blocks it uses.

| Adapter                                   | Subpath                                                                               | Uses the SQL modules of                                                                          |
| ----------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| [AI SDK](/docs/ai-sdk)                    | `better-supabase/ai-sdk` and its `chat`, `files`, `cache`, `memory` and other entries | `ai-chat`, `ai-files`, `ai-cache`, `memory`, `knowledge`, `agents`, `connectors`, `ai-providers` |
| [Chat SDK](/docs/chat-sdk)                | `better-supabase/chat-sdk`, `better-supabase/chat-sdk/react`                          | `chat-sdk-state`, `inbox`                                                                        |
| [Workflow SDK](/docs/blocks/workflow-sdk) | `better-supabase/workflow-sdk`, `better-supabase/workflow-sdk/world`                  | `workflow-sdk-world`, `workflows`                                                                |
| [eve](/docs/eve)                          | `better-supabase/eve`                                                                 | `workflow-sdk-world`, `ai-chat`, `memory`, `knowledge`, `credentials`, `inbox`                   |

## How modules work together [#how-modules-work-together]

`sql add` installs a module with the modules it requires. The modules it
works with are optional: when one of them is installed too, the module calls
it (it queues a job, sends a notification, checks a plan), and without it the
module installs and runs without that step. Every module also writes its
events to the [outbox](/docs/blocks/outbox) when the outbox is installed.
`better-supabase sql list` prints both lists for each module.

| Module               | Requires                                | Works with                                                                 |
| -------------------- | --------------------------------------- | -------------------------------------------------------------------------- |
| `updated-at`         | none                                    | none                                                                       |
| `actor`              | none                                    | none                                                                       |
| `audit`              | none                                    | `access`, `organizations`                                                  |
| `tenant`             | `updated-at`                            | `access`, `invitations`, `organizations`                                   |
| `invitations`        | `tenant`, `access`, `updated-at`        | `organizations`, `profiles`                                                |
| `reserved-slugs`     | none                                    | none                                                                       |
| `jobs`               | none                                    | none                                                                       |
| `idempotency`        | none                                    | none                                                                       |
| `webhook-inbox`      | none                                    | none                                                                       |
| `realtime-tables`    | none                                    | none                                                                       |
| `jsonb-schemas`      | none                                    | none                                                                       |
| `pgtap`              | none                                    | none                                                                       |
| `grants`             | none                                    | none                                                                       |
| `read-sets`          | none                                    | none                                                                       |
| `mfa`                | none                                    | none                                                                       |
| `entitlements`       | `tenant`                                | none                                                                       |
| `rate-limit`         | none                                    | none                                                                       |
| `vector-search`      | none                                    | none                                                                       |
| `access`             | `tenant`                                | `invitations`, `organizations`                                             |
| `support-sessions`   | `access`, `audit`                       | `invitations`                                                              |
| `organizations`      | `tenant`, `access`, `updated-at`        | `audit`, `data-lifecycle`, `entitlements`, `invitations`, `reserved-slugs` |
| `profiles`           | none                                    | `tenant`                                                                   |
| `outbox`             | none                                    | none                                                                       |
| `notifications`      | `updated-at`                            | `access`, `profiles`                                                       |
| `webhooks-out`       | `updated-at`                            | `access`                                                                   |
| `sessions`           | none                                    | none                                                                       |
| `webhooks-in`        | `access`, `updated-at`, `webhook-inbox` | none                                                                       |
| `api-keys`           | `tenant`, `access`                      | none                                                                       |
| `settings`           | `tenant`, `access`                      | `jsonb-schemas`                                                            |
| `usage`              | `tenant`, `access`                      | `entitlements`                                                             |
| `billing`            | `tenant`, `access`                      | `organizations`                                                            |
| `flags`              | `tenant`                                | `access`, `entitlements`                                                   |
| `comments`           | `tenant`, `access`                      | `jsonb-schemas`, `notifications`                                           |
| `attachments`        | `tenant`, `access`                      | none                                                                       |
| `data-lifecycle`     | `tenant`, `access`                      | every installed module                                                     |
| `sso`                | `tenant`, `access`                      | `organizations`                                                            |
| `onboarding`         | `tenant`, `access`                      | `outbox`                                                                   |
| `waitlist`           | `tenant`, `access`                      | `invitations`, `organizations`                                             |
| `announcements`      | `tenant`, `access`                      | `entitlements`                                                             |
| `streams`            | none                                    | none                                                                       |
| `credentials`        | none                                    | none                                                                       |
| `workflows`          | `tenant`, `access`                      | none                                                                       |
| `workflow-sdk-world` | `workflows`, `jobs`, `access`           | none                                                                       |
| `workflow-builder`   | `workflows`, `tenant`, `access`         | none                                                                       |
| `chat-sdk-state`     | none                                    | none                                                                       |
| `inbox`              | `tenant`, `access`, `updated-at`        | `jobs`, `notifications`                                                    |
| `ai-chat`            | `tenant`, `access`, `streams`           | `entitlements`, `ai-providers`                                             |
| `ai-files`           | `tenant`, `access`                      | `ai-chat`                                                                  |
| `knowledge`          | `tenant`, `access`, `vector-search`     | `ai-chat`, `ai-files`, `jobs`                                              |
| `memory`             | `tenant`, `access`, `vector-search`     | `ai-chat`                                                                  |
| `agents`             | `tenant`, `access`                      | none                                                                       |
| `connectors`         | `tenant`, `access`                      | none                                                                       |
| `ai-tasks`           | `tenant`, `access`                      | `agents`, `ai-chat`, `jobs`                                                |
| `push`               | none                                    | none                                                                       |
| `ai-cache`           | none                                    | none                                                                       |
| `ai-providers`       | `tenant`, `access`                      | `ai-chat`                                                                  |
| `ensure-rls`         | none                                    | none                                                                       |

The other SQL modules (`updated-at`, `actor`, `rate-limit`,
`support-sessions` and the rest) are listed on the
[SQL modules](/docs/blocks/sql#modules) page. The
[roadmap](/docs/roadmap) lists what comes next.

# Jobs, idempotency and webhook inbox

> Background jobs on Supabase Queues, safe retries and exactly-once webhook handling on the Postgres you already have.

Source: https://bettersupabase.com/docs/blocks/jobs

`better-supabase/blocks/jobs` sits on top of the `jobs`, `idempotency` and
`webhook-inbox` [SQL modules](/docs/blocks/sql). It needs a service-role
connection, such as `createPostgres(...).admin`, because the functions are
closed to `anon` and `authenticated`. Every call returns a `Result`.

```bash
better-supabase sql add jobs idempotency webhook-inbox
```

## Jobs [#jobs]

Jobs run on [Supabase Queues](https://supabase.com/docs/guides/queues)
(`pgmq`) by default, so queues and messages show up in the dashboard. The
`jobs` module installs `pgmq` and adds thin `better_supabase.*` functions for
what pgmq doesn't do itself: lease-safe completion, retries with backoff,
dead letters, deduplication keys and schedules.

Two module options change where jobs and schedules live. Both keep the same
functions, so the TypeScript side doesn't change:

| Option                               | Values                       | What it does                                                                                                                                                 |
| ------------------------------------ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `sql.modules.jobs.options.backend`   | `pgmq` (default), `table`    | `table` stores jobs in `better_supabase.job_messages` and claims them with `for update skip locked`, for projects without pgmq                               |
| `sql.modules.jobs.options.scheduler` | `pg_cron` (default), `drain` | `drain` stores schedules in `better_supabase.job_schedules`, with a time zone each, and the [drain route](#drain-route) enqueues them; pg\_cron isn't needed |

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: { jobs: { options: { backend: "table", scheduler: "drain" } } },
  },
});
```

On the pgmq backend, the module's data file also runs `create extension if
not exists pgmq`, so `better-supabase sql data` puts the extension in a
migration even when the schema diff leaves it out (pg-delta can, because
pgmq owns its own schema). Doctor warns about an extension no migration
creates ([BS321](/docs/cli/doctor#bs321)).

Run `better-supabase sql sync` after changing either option. On the table
backend, completed and dead jobs stay in `job_messages` with `archived_at`
set. `better_supabase.purge_job_archive(queue, older_than, batch,
dead_older_than)` deletes completed jobs after `older_than` (7 days) and
dead letters after `dead_older_than` (30 days) on both backends, so dead
letters stay around long enough to replay.

Declare queues with a Standard Schema for their payload. `enqueue`
validates the payload, so a wrong payload is a type error at compile time
and a `validation` error at runtime. Queue names follow pgmq's rule:
lowercase letters, digits and underscores.

```ts title="lib/jobs.ts"
import { createJobs } from "better-supabase/blocks/jobs";
import * as v from "valibot";

export const jobs = createJobs(postgres.admin, {
  send_email: v.object({
    to: v.pipe(v.string(), v.email()),
    template: v.string(),
  }),
});

await jobs.enqueue(
  "send_email",
  { to, template: "welcome" },
  { delay: 300, dedupeKey: `welcome:${userId}` },
);
```

`delay` is in seconds. To run at a fixed time, pass `runAt` as a
`Temporal.Instant`. Jobs and inbox messages report `enqueuedAt`,
`visibleUntil` and `receivedAt` as `Temporal.Instant` too (see
[Temporal](/docs/concepts/temporal)). The third argument takes `temporal`, for
runtimes without a global `Temporal`, and `now`, the clock for `runAt` delays
and schedule runs. Pass the same `now` you give `defineSupabase` so jobs and
repositories share one clock in tests.

Any number of workers can run side by side: pgmq hides a claimed message for
the length of its lease.

```ts title="worker.ts"
const controller = new AbortController();
await jobs.work(
  "send_email",
  async (payload, job, signal) => {
    await sendEmail(payload, { signal });
  },
  { concurrency: 4, lease: 60, signal: controller.signal },
);
```

* A job is done when its handler returns; it is archived (`pgmq.a_<queue>`).
  If the handler throws or returns a failed `Result`, the job is retried
  with exponential backoff and full jitter: a random wait up to 10s, 20s,
  40s and so on, at most an hour, so failed jobs don't retry in bursts.
  `last_error` is kept on the message. After `maxAttempts` (5 by default) it
  is archived with `dead: true`. A job whose worker died on its last attempt
  is archived as dead by the next claim instead of running again.
* `jobs.replay(queue, id)` (SQL `better_supabase.replay_dead_job`) enqueues
  a dead letter again with its payload, attempts and dedupe key, and removes
  it from the archive.
* While a batch runs, a heartbeat keeps extending the lease of every job in
  it that hasn't finished, including the ones waiting their turn. If a worker
  crashes, the lease ends and another worker picks the job up. On pgmq the
  lease is extended with `pgmq.set_vt`. `job.attempts` is pgmq's `read_ct`;
  a worker whose lease was taken over gets `false` from
  `complete` instead of finishing someone else's job.
* An idle worker polls after `pollInterval` (1 second), then doubles the wait
  after each empty poll up to `maxPollInterval` (30 seconds). The next claimed
  job resets it.
* A claim that fails with a `network`, `timeout`, `serialization` or
  `rate_limited` error is retried up to 3 times, waiting `pollInterval` and
  then twice as long each time. Any other claim error, or a fourth in a row,
  stops every lane, and `work` or `drain` rejects with a `TypeError` whose
  `cause` is the `DbError`.
* A `dedupeKey` keeps one waiting or running job per key. With
  `dedupe: "waiting"` it coalesces only onto a job no worker has claimed
  yet, so a change made while the job runs queues one follow-up instead of
  being dropped: a debounce per entity. In SQL this is
  `enqueue_job(..., dedupe_key => key, dedupe_running => false)`.
* `drain(queue, handler)` works the queue until it is empty, which suits cron
  and Edge Functions. `claim`, `complete`, `fail` and `extend` are available
  when you want to drive the queue yourself.

### Queue health [#queue-health]

An admin page reads each queue's state with `stats`, pages through dead
letters with `listDead`, and puts them back with `retryDead`:

```ts
const stats = await jobs.stats().orThrow();
// { send_email: { ready, inFlight, delayed, dead, oldestAgeSeconds } }

const dead = await jobs.listDead("send_email", { limit: 50 }).orThrow();
// [{ id, payload, context, attempts, maxAttempts, lastError, enqueuedAt, diedAt }]
const next = await jobs
  .listDead("send_email", { limit: 50, cursor: dead.at(-1)?.id })
  .orThrow();

await jobs.retryDead("send_email", { ids: [dead[0]!.id] }).orThrow();
await jobs.retryDead("send_email").orThrow(); // the newest 1000
```

| Field              | Counts                                                              |
| ------------------ | ------------------------------------------------------------------- |
| `ready`            | messages the next claim takes                                       |
| `inFlight`         | messages a worker holds, or that wait out a retry backoff           |
| `delayed`          | messages enqueued for later that no worker claimed yet              |
| `dead`             | dead letters in the archive, until `purge_job_archive` removes them |
| `oldestAgeSeconds` | the age of the oldest message that isn't done, null when none       |

`stats` takes a list of queue names and defaults to every queue
`createJobs` declared. `listDead` returns stored payloads without
validating them, since the schema may have changed since. All three call
`better_supabase.job_queue_stats`, `list_dead_jobs` and `retry_dead_jobs`,
so they need a SQL connection.

### Actor and tenant [#actor-and-tenant]

The queue itself runs on a service-role connection, but the work inside a
handler should run as the user who asked for it, so your RLS policies still
decide what it can read and write.

Pass the request context to `enqueue` and the job records its actor and
tenant next to the payload. The tenant is `context.tenant`, the one
`tenant()` resolved for the connection, or the `tenant_id` claim (then
`app_metadata.tenant_id`). The handler gets them back as `job.context`, and
[`bs.forContext`](/docs/auth/server#explicit-identities) turns that into
repositories running as that user, in that tenant, over direct Postgres:

```ts
await jobs.enqueue("send_invoice", { invoiceId }, { context: db.$context });

await jobs.work("send_invoice", async (payload, job) => {
  const db = await bs.forContext(job.context).orThrow();
  const invoice = await db.invoices.findById(payload.invoiceId).orThrow();
  // ...
});
```

A job enqueued in a support session or an impersonated session also records
the session's `act` claim. `forContext` keeps it, so a job enqueued in a
read-only support session runs read-only, as the same support session.

`forContext` fails with `forbidden` when the job recorded no user, for
example one enqueued by a cron schedule, and never falls back to the service
role. Work that no user owns uses `bs.admin(job.context)` on purpose: it
stamps `createdBy` and applies the `tenant()` filter, but bypasses RLS, so
keep it visible in code review. `admin.$with(job.context)` is the same
service-role connection and does not apply RLS either. See
[Jobs, webhooks and agents without a session](/docs/guides/without-a-session).

A job enqueued without a tenant runs with a context that has none, so
`tenant()` applies its `onMissing` (a `forbidden` error by default). Workers
that handle cross-tenant jobs on purpose pass `allTenants: true` to `work` or
`drain`; jobs that recorded no tenant then run without the tenant scope, and
jobs that did keep theirs. `schedule` takes the same `{ context }` as its last
argument.

### Schedules [#schedules]

`schedule` enqueues a payload on a cron schedule. Scheduling the same name
again replaces it. A schedule is five cron fields (lists, ranges, steps, and
names such as `MON-FRI`), a macro (`@hourly`, `@daily`, `@weekly`,
`@monthly`, `@yearly`) or an interval (`30 seconds`, `5 minutes`). An
invalid schedule returns an error before anything is written.

```ts
await jobs.schedule("nightly-digest", "0 3 * * *", "send_email", {
  to: "team@example.com",
  template: "digest",
});
await jobs.unschedule("nightly-digest");
```

With the default `pg_cron` scheduler, enable the
[pg\_cron](https://supabase.com/docs/guides/cron) extension first. pg\_cron
reads every schedule in `cron.timezone` (UTC on Supabase), so a `timeZone`
other than `UTC` returns an error.

With `scheduler: "drain"`, each schedule has its own time zone, and the
drain route enqueues a run when one is due:

```ts
await jobs.schedule(
  "weekday-digest",
  "0 9 * * MON-FRI",
  "send_email",
  { to: "team@example.com", template: "digest" },
  { timeZone: "Europe/Amsterdam" },
);
```

The next run is computed in that zone, so 09:00 stays 09:00 across daylight
saving changes. A time that a change skips (02:30 on the night clocks move
forward) runs right after the gap. When the drain route didn't run for a
while, missed runs collapse into one. `runSchedules()` does the same work
without the route, and `nextCronRun(cron, timeZone, after)` is exported for
your own checks.

Under the drain scheduler, a schedule also belongs to a tenant: `tenant` in
the options, or the tenant of `context`. Each named schedule keeps its own
cron, time zone and tenant, and its runs record the context, so a handler
runs for that tenant. A product page reads schedule state with
`listSchedules`, and tenant deletion removes them all with `unscheduleAll`:

```ts
await jobs.schedule(
  `workflow:${definitionId}`,
  "0 8 * * MON",
  "run_workflow",
  { definitionId },
  { timeZone: "Europe/Amsterdam", context: { tenant: organizationId } },
);

const schedules = await jobs
  .listSchedules({ prefix: "workflow:", tenant: organizationId })
  .orThrow();
// [{ name, cron, timeZone, queue, tenant, nextRun, lastRun, leasedUntil, createdAt }]

await jobs.unscheduleAll({ tenant: organizationId }).orThrow();
```

Under pg\_cron, `listSchedules` returns pg\_cron's jobs with only the name and
cron, and a schedule with a tenant returns an error.

`ensureSchedules` keeps a whole set in step with a source of truth, such as
the app's built-in schedules on deploy or a tenant's workflow settings after
an edit. It writes every definition, removes the schedules under `prefix`
(and `tenant`, when given) that the set no longer names, and returns both
lists. Every name must start with the prefix, and every payload and cron is
checked before anything is written. A definition whose cron and time zone
didn't change keeps its next run, so a run that is due but not yet drained
still happens and running it twice changes nothing.

```ts
const result = await jobs
  .ensureSchedules(
    workflows.map((workflow) => ({
      name: `workflow:${workflow.id}`,
      cron: workflow.cron,
      queue: "run_workflow",
      payload: { definitionId: workflow.id },
      timeZone: workflow.timeZone,
    })),
    { prefix: "workflow:", tenant: organizationId },
  )
  .orThrow();
// { scheduled: ["workflow:..."], removed: ["workflow:..."] }
```

Definitions take the same `timeZone`, `tenant` and `context` as `schedule`;
one without a tenant gets the `tenant` option.

#### Schedules from SQL [#schedules-from-sql]

Under the drain scheduler, a database trigger or function can schedule a job
itself with `better_supabase.schedule_job`. Leave out `next_run`: the
schedule is stored without one, and the next drain computes its first run
in the schedule's time zone, counted from when it was written, so a run
that falls between the write and the drain still happens. The app needs no
job that copies rows into schedules: a trigger on the table that holds the
cron keeps the schedule current, and the drain route runs it.

```sql title="supabase/schemas/040_reminders.sql"
create function public.schedule_reminder()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
  perform better_supabase.schedule_job(
    'reminder:' || new.id,
    new.cron,
    'send_reminder',
    jsonb_build_object('id', new.id),
    new.time_zone,
    null,
    new.organization_id::text
  );
  return null;
end;
$$;

create trigger schedule_reminder
  after insert or update of cron, time_zone on public.reminders
  for each row execute function public.schedule_reminder();
```

Make the trigger function `security definer`, so a signed-in user's insert
can write the schedule without access to `better_supabase`. Writing a
schedule again with the same cron and time zone keeps its next run; a new
cron or time zone clears it, and the next drain computes the first run of
the new one. Call `better_supabase.unschedule_job('reminder:' || old.id)`
from a delete trigger to remove it.

`schedule_job` rejects a schedule that isn't five cron fields, a macro or an
interval. A cron that passes that check but that the drain can't read (a
field out of range) stays without a next run, and `listSchedules` shows its
`nextRun` as null.

#### External schedulers [#external-schedulers]

The drain scheduler needs no database extension: any scheduler that calls the
drain route on a fixed cadence runs every schedule, whatever their own
cadences. Call it at least as often as your most frequent schedule, every
minute for minute-level schedules. Vercel Cron, a GitHub Actions workflow, a
Kubernetes CronJob or a Supabase Edge Function on a timer all work; each call
leases the due schedules, so overlapping calls never enqueue a run twice.

#### Three scheduling layers [#three-scheduling-layers]

Three blocks take a cron, and each one is for a different owner. All three
parse the cron and compute the next run with the same helpers
(`assertCron` and `nextCronRun`), so a cron and a time zone mean the same
thing in each.

| Layer                                                  | Who sets it                               | What a run does                                                             |
| ------------------------------------------------------ | ----------------------------------------- | --------------------------------------------------------------------------- |
| Job schedules (this block)                             | The app, in code or SQL                   | Enqueues a job with a fixed payload                                         |
| [Workflow schedules](/docs/blocks/workflows#schedules) | The app, or members with `workflow.admin` | Starts a workflow run, with the idempotency key `schedule:<id>:<fire time>` |
| [AI tasks](/docs/blocks/ai-tasks)                      | Each user, for their own prompt           | Runs the prompt and logs the chat each run wrote to                         |

Pick the lowest layer that does the job. A nightly cleanup is a job
schedule. A recurring start of a durable workflow that members can see and
pause is a workflow schedule. A prompt a user writes and schedules in the
product is an AI task, which the jobs block then runs.

### Drain route [#drain-route]

`drainRoute` turns the queues into an HTTP endpoint for a cron caller such as
[Vercel Cron](https://vercel.com/docs/cron-jobs). Each call checks the bearer
secret, enqueues due schedules, then drains each queue in `handlers` until
they are empty or `budgetMs` (50 seconds by default) is spent. Jobs it
already claimed still finish.

```ts title="app/api/jobs/drain/route.ts"
import "server-only";

import { jobs } from "@/lib/jobs";

export const GET = jobs.drainRoute({
  secret: process.env.CRON_SECRET,
  budgetMs: 50_000,
  handlers: {
    send_email: (payload) => sendEmail(payload),
  },
  onError: (error, job) => console.error(error.message, job?.id),
});
```

```json title="vercel.json"
{
  "crons": [{ "path": "/api/jobs/drain", "schedule": "* * * * *" }]
}
```

Vercel sends `Authorization: Bearer <CRON_SECRET>`; a request without it gets
`401`. The response is JSON: `schedules` (runs enqueued), `queues` (the
succeeded and failed counts per queue), `budgetExhausted` and `errors` (the
schedule and queue runs that failed as a whole). Keep `budgetMs` below the
function's maximum duration. `drain` takes the same `budgetMs` when you call
it yourself.

Nothing calls the route under `next dev` or another local server, so
outbox relays and queued jobs wait until a deploy. `devDrain` calls it on an
interval (every minute by default) in development, and `devDrainSecret`
generates the route's secret when none is set. Call both before the route
reads the secret, for example in Next.js `instrumentation.ts`:

```ts title="instrumentation.ts"
import { devDrain, devDrainSecret } from "better-supabase/blocks/jobs";

export function register() {
  if (process.env.NODE_ENV !== "development") return;
  if (process.env.NEXT_RUNTIME !== "nodejs") return;
  devDrain({
    url: `http://localhost:${process.env.PORT ?? "3000"}/api/jobs/drain`,
    secret: devDrainSecret(process.env),
  });
}
```

`devDrain` sends `GET` with `Authorization: Bearer <secret>`, skips a call
while the previous one still runs, and reports a failed call or an answer
other than 2xx to `onError` (`console.warn` by default) without stopping.
`every` sets the interval in milliseconds; `stop()` or an aborted `signal`
ends it, and `tick()` calls the route once. `devDrainSecret(env, name)`
returns `env[name]` (`CRON_SECRET` by default) or writes a random one there.

`monitor` hooks around each authorized drain report to a cron monitor
without wrapping the route. `onStart` returns a value, such as a check-in
id, that `onFinish` receives with the result. A hook that throws goes to
`onError` and never fails the drain.

```ts
export const GET = jobs.drainRoute({
  secret: process.env.CRON_SECRET,
  handlers,
  monitor: {
    onStart: () =>
      Sentry.captureCheckIn({
        monitorSlug: "jobs-drain",
        status: "in_progress",
      }),
    onFinish: (result, checkInId) =>
      Sentry.captureCheckIn({
        checkInId: String(checkInId),
        monitorSlug: "jobs-drain",
        status: result.errors > 0 ? "error" : "ok",
      }),
  },
});
```

### Queue backends [#queue-backends]

`createJobs` accepts a SQL connection, a Supabase client, or any
`QueueBackend` (API version 1). `sqlQueueBackend(sql)` and
`pgmqPublicBackend(client)` are the two built in; implement the interface to
put jobs somewhere else, and prove it with `testQueueBackend` from
[conformance kits](/docs/extending/conformance).

```ts
import { createJobs, type QueueBackend } from "better-supabase/blocks/jobs";

const backend: QueueBackend = myQueue;
const jobs = createJobs(backend, { send_email: schema });
```

### Without a database connection [#without-a-database-connection]

Where there's no direct connection, such as an Edge Function without a pooler
URL, pass a service-role Supabase client instead. Jobs then go through the
`pgmq_public` RPCs, which you turn on in the dashboard under Integrations, Queues, "Expose
Queues via PostgREST".

```ts
const jobs = createJobs(createClient(url, secretKey), { send_email: schema });
```

Over PostgREST you get `enqueue`, `claim`, `complete`, `fail`, `drain` and
`work`, with these differences:

* there is no lease check on `complete`;
* a failed job reappears when its lease ends (`retryIn` is ignored), and
  there's no heartbeat;
* `dedupeKey`, `schedule`, `stats`, `listDead` and `retryDead` return
  `invalid_request`.

## Idempotency keys [#idempotency-keys]

`handle` wraps a handler so that a retried request with the same
`Idempotency-Key` gets the first response back instead of running twice:

```ts
import { createIdempotency } from "better-supabase/blocks/jobs";

const idempotency = createIdempotency(postgres.admin, { ttl: "24 hours" });

export const POST = (request: Request) =>
  idempotency.handle(request, async () =>
    Response.json(await createOrder(request), { status: 201 }),
  );
```

| Situation                                         | Response                                               |
| ------------------------------------------------- | ------------------------------------------------------ |
| First request                                     | The handler's response, stored                         |
| Same key and same body                            | The stored response, with `idempotency-replayed: true` |
| Same key while the first request is still running | `409` with `Retry-After`                               |
| Same key and a different body                     | `422` with code `IDEMPOTENCY_KEY_REUSED`               |
| Handler throws or returns a 5xx                   | The key is released, so the client can retry           |

The fingerprint is a SHA-256 of the method, path and body. Set
`required: true` to reject requests that have no key. The error responses
are Problem Details; pass `problem` to answer in your app's error format
instead (see [Problem Details](/docs/auth/problems#your-own-error-format)).

Outside HTTP, `begin(key, fingerprint, scope)` returns a `holder` token with
the `started` state, and `complete(key, holder, status, body, scope)` and
`release(key, holder, scope)` need it. A caller whose lock ran out may find
that another caller took the key over; its `complete` and `release` then
return `false` and change nothing, so it can't store its late result over
the new holder's or free a key someone else holds:

```ts
const started = await idempotency.begin(key, fingerprint).orThrow();
if (started.state === "started") {
  const stored = await idempotency
    .complete(key, started.holder!, 200, result)
    .orThrow();
  if (!stored) console.warn("lost the key to another caller");
}
```

### Leases [#leases]

`withLease(sql, key, fn)` runs `fn` while it holds the lease on `key`, so
work that must not run twice at once (one automation per conversation, one
mutation per agent run) runs once, and other callers get
`{ acquired: false }`. The lease holds for `seconds` (60 by default) and is
released when `fn` ends or throws; for long work, `lease.extend()` moves
the expiry and returns `false` once the lease was lost:

```ts
import { withLease } from "better-supabase/blocks/jobs";

const outcome = await withLease(
  postgres.admin,
  `conversation:${conversationId}`,
  async (lease) => {
    await step1();
    await lease.extend();
    return step2();
  },
  { seconds: 30 },
).orThrow();
if (!outcome.acquired) return; // another worker has it
```

In SQL the same lease is `better_supabase.acquire_lease(key, seconds,
scope)`, which returns a holder token or null, `extend_lease(key, holder,
seconds, scope)` and `release_lease(key, holder, scope)`, for the service
role. An expired lease goes to the next caller, and the former holder's
`extend_lease` and `release_lease` return false.

## Webhook inbox [#webhook-inbox]

The inbox verifies a webhook, stores it once, and returns quickly. A
worker then processes it, with the same retries as jobs. This way your
provider never times out, and a redelivered webhook is only handled once.

```ts
import { createWebhookInbox } from "better-supabase/blocks/jobs";

const inbox = createWebhookInbox(postgres.admin, {
  source: "billing",
  secrets: process.env.BILLING_WEBHOOK_SECRET!,
});

export const POST = (request: Request) => inbox.receive(request);

await inbox.process(async (message) => {
  if (message.type === "invoice.paid") await markPaid(message.payload);
});
```

`createWebhookInbox` was called `createInbox` before 0.6, and its types were
`Inbox`, `InboxOptions` and so on. 0.6 removes the old names;
`createInbox` from `better-supabase/blocks/inbox` is the conversation inbox
block.

`secrets` verifies [Standard Webhooks](/docs/standards) signatures. Pass
`verify` to use a provider's own scheme instead. `receive` answers `202` for
a new message and `200` for a duplicate. A message id is unique per source
and tenant, so two tenants of one provider can send the same id. A failed
verification gets a Problem Details response, and a body over `maxBodyBytes`
(1 MiB by default) gets `413` before it is read in full.

A failed message is retried with exponential backoff until `maxAttempts`
(8 by default, set per source on `createWebhookInbox`), then marked dead. Each
message stores the limit it arrived with. The handler sees `attempts` (1 on
the first) and `maxAttempts`, so it can tell its last attempt, for example
to notify someone before the message is given up:

```ts
const crm = createWebhookInbox(postgres.admin, {
  source: "crm",
  secrets,
  maxAttempts: 3,
});

await crm.process(async (message) => {
  const result = await syncContact(message.payload);
  if (!result.ok && message.attempts === message.maxAttempts) {
    await alertOwner(message, result.error);
  }
  return result;
});
```

A message whose worker died during its last attempt is marked dead by the
next `process` instead of running again.

`process` runs until no message is ready. In a serverless function, pass
`budgetMs` so a backlog can't outrun the function's maximum duration: it
stops claiming once the budget is spent, finishes the messages it already
claimed, and the next call picks up the rest. `batch` (10) and `lease` (300
seconds) set how many messages one claim takes and how long they stay with
the worker. While a handler runs, a heartbeat renews its message's lease
every half lease. A worker whose lease ran out and was taken by another
claim can't complete, fail or checkpoint that message: the inbox checks the
attempt it claimed, and skips a message whose lease it can't renew.

```ts title="app/api/inbox/route.ts"
export const GET = async () =>
  Response.json(await inbox.process(handleMessage, { budgetMs: 50_000 }));
```

A webhook carries no session, so pick the identity in the handler. Look up
the user and tenant the event belongs to (a Stripe customer id maps to an
organization and its owner), then run the work with
`bs.actingAs(userId, { tenant_id })` so RLS applies:

```ts
await inbox.process(async (message) => {
  const owner = await ownerOfCustomer(message.payload.customer);
  const db = bs.actingAs(owner.userId, { tenant_id: owner.organizationId });
  await db.invoices
    .update(message.payload.invoice, { status: "paid" })
    .orThrow();
});
```

Use `bs.admin()` only for events no user owns, such as a provider-wide
status change. See
[Jobs, webhooks and agents without a session](/docs/guides/without-a-session).

### Per-tenant integrations [#per-tenant-integrations]

Most inboxes serve integrations a tenant connected, so a message can belong to
a tenant: `tenantOf(payload)` reads it from the payload, and `verify` can
return it next to the id and payload. `list({ tenant })` shows a tenant's
messages newest first (an integration's delivery log), and
`purge({ tenant, olderThan: 0 })` removes them when the tenant goes.

```ts
const inbox = createWebhookInbox(postgres.admin, {
  source: "chat-provider",
  secrets: process.env.CHAT_WEBHOOK_SECRET!,
  tenantOf: (payload) => connectionTenant(payload),
});

const deliveries = await inbox
  .list({ tenant: organizationId, status: "dead" })
  .orThrow();
```

A provider that pages its deliveries needs progress that survives a retry.
`message.checkpoint(fields)` merges `fields` into the stored progress while
the worker holds the message, and the next attempt reads it from
`message.progress`:

```ts
await inbox.process(async (message) => {
  let cursor = message.progress.cursor as string | undefined;
  do {
    const page = await provider.fetchPage(message.payload, cursor);
    await apply(page.items);
    cursor = page.next;
    await message.checkpoint({ cursor });
  } while (cursor);
});
```

`inbox.store({ id, payload, type, tenant })` stores an event your code
already verified, for providers whose SDK verifies and parses in one call:

```ts
export async function POST(request: Request) {
  const event = await stripe.webhooks.constructEventAsync(
    await request.text(),
    request.headers.get("stripe-signature")!,
    process.env.STRIPE_WEBHOOK_SECRET!,
  );
  const stored = await inbox.store({
    id: event.id,
    type: event.type,
    payload: event,
  });
  return stored.ok
    ? new Response(null, { status: 202 })
    : new Response(null, { status: 500 });
}
```

A source that only `store` fills, such as a chat SDK that verifies its own
requests, needs neither `secrets` nor `verify`. Its `process`, `list` and
`purge` work as usual, and `receive` throws a `TypeError`, so an unverified
request is never stored:

```ts
const chat = createWebhookInbox(postgres.admin, { source: "chat" });

await chat.store({ id: event.id, type: event.type, payload: event });
```

# Knowledge

> Documents chunked and embedded per organization, agent, project, chat or user, with hybrid search that ranks full text and vector matches together.

Source: https://bettersupabase.com/docs/blocks/knowledge

The `knowledge` block stores what an assistant can look things up in:
documents, split into chunks that each carry an embedding and a
`tsvector`. Search runs full text and vector similarity and fuses the two
rankings with reciprocal rank fusion, so a query finds both the exact term
and a paraphrase. Row level security decides which documents a caller
sees.

```bash
better-supabase sql add knowledge   # adds tenant, access and vector-search as well
```

| Table                 | Holds                                                                                                  |
| --------------------- | ------------------------------------------------------------------------------------------------------ |
| `knowledge_documents` | One row per document: tenant, owner, `scope` and `scope_id`, `title`, `source`, `metadata`, `status`   |
| `knowledge_chunks`    | The chunks of a document in order: `content`, `token_count`, `embedding`, `embedding_model`, the `tsv` |

A document belongs to one scope:

| Scope          | `scope_id`                         | Who reads it                             |
| -------------- | ---------------------------------- | ---------------------------------------- |
| `organization` | none                               | every member with the read permission    |
| `agent`        | the agent                          | every member with the read permission    |
| `project`      | the project                        | the owner                                |
| `chat`         | an [AI chat](/docs/blocks/ai-chat) | the owner, and whoever can read the chat |
| `user`         | the owner (the caller by default)  | the owner                                |

Members with the manage permission read every document in the tenant.
A new document is `user` scoped unless you pass `scope`.

| Permission  | Lets a member                                        | Default roles              |
| ----------- | ---------------------------------------------------- | -------------------------- |
| `ai.read`   | read organization and agent documents                | `owner`, `admin`, `member` |
| `ai.create` | add `user`, `project` and `chat` documents           | `owner`, `admin`, `member` |
| `ai.admin`  | add organization and agent documents, read every one | `owner`, `admin`           |

Rename the keys with `sql.modules.knowledge.permissions.read`, `.write`
and `.manage`.

| Option       | Default           | Sets                                                                     |
| ------------ | ----------------- | ------------------------------------------------------------------------ |
| `dimensions` | `1536`            | The embedding size; 384 for `gte-small` in Edge Functions                |
| `type`       | `vector`          | `vector` or `halfvec`, as in [vector search](/docs/blocks/vector-search) |
| `textSearch` | `simple`          | The text search configuration of the `tsv` column, such as `english`     |
| `embedQueue` | `knowledge_embed` | The [jobs](/docs/blocks/jobs) queue a new document is enqueued on        |

## Server [#server]

```ts title="lib/knowledge.ts"
import { embedWith } from "better-supabase/ai-sdk/embeddings";
import {
  createKnowledge,
  rpcTransport,
} from "better-supabase/blocks/knowledge";

export const knowledgeFor = (supabase: SupabaseClient, admin: SupabaseClient) =>
  createKnowledge({
    transport: rpcTransport(supabase),
    service: rpcTransport(admin),
    embedder: embedWith("openai/text-embedding-3-small"),
  });
```

`transport` acts as the user. `service` writes embeddings, which only the
server does. The `embedder` is anything with a `model` name and an
`embed(values)` method; [`better-supabase/ai-sdk/embeddings`](/docs/ai-sdk/embeddings)
builds one from an AI SDK model, and `files` (an [AI files](/docs/blocks/ai-files)
client) lets `ingest.file` read uploads.

| Group       | Methods                                             |
| ----------- | --------------------------------------------------- |
| `documents` | `create`, `get`, `list`, `write`, `remove`          |
| `ingest`    | `text`, `file`                                      |
| (top level) | `process`, `embedJob`, `drain`, `search`, `reembed` |

## Ingest and embed [#ingest-and-embed]

```ts
const doc = await knowledge.ingest
  .text(organizationId, { title: "Refund policy", text, source: "handbook" })
  .orThrow();
```

`ingest.text` creates the document and writes its chunks with
`chunk(text)`: about 2,000 characters each, cut at a paragraph, line,
sentence or word break, with a short overlap. The document starts
`pending`. Embedding happens on the server:

* With the [`jobs`](/docs/blocks/jobs) module installed, a new document is
  enqueued on `knowledge_embed`; register `knowledge.embedJob()` as its
  handler. The last failed attempt marks the document `failed` with the
  error.
* Without a queue, call `knowledge.process(doc.id)` (in `after()`, for
  example) or `knowledge.drain()` from a cron job. `drain` tries each
  document `attempts` times (3 by default) and marks it `failed` only
  after the last attempt.

Each pending chunk comes with a hash of its text, and an embedding is
written only while the chunk still has that text: a chunk rewritten during
the embedding call stays pending for the next round. The embedder must
return one finite vector per chunk, all of one length; anything else fails
with `invalid_input` and the hint `EMBEDDING_INVALID`.

`documents.write(id, chunks)` replaces the chunks; a chunk whose text and
model didn't change keeps its embedding, so editing one paragraph re-embeds
one chunk. `reembed({ model })` marks documents embedded by another model
`pending` again after you change models.

`ingest.file(organizationId, fileId)` reads a ready AI files upload. It
extracts text, JSON, XML and YAML; pass `extract` to `createKnowledge` for
PDFs and other formats.

## Search [#search]

```ts
const hits = await knowledge
  .search(organizationId, "how fast are refunds paid", {
    scopes: [{ scope: "organization" }, { scope: "chat", id: chatId }],
    k: 8,
  })
  .orThrow();
```

Each hit has the `documentId`, the chunk `index`, its `content`, the
document `title` and a fused `score`. Without an embedder the search is
full text only; pass `{ text, embedding }` to reuse an embedding you
already have. `filter` matches documents whose `metadata` contains an
object.

## AI SDK [#ai-sdk]

[`better-supabase/ai-sdk/embeddings`](/docs/ai-sdk/embeddings) gives you
the embedder, a reranker, a search tool for the model and source parts
for the answer.

# Memory

> Core memory files the model edits with Anthropic's memory tool commands, archival facts found by similarity, and recall over a user's earlier messages.

Source: https://bettersupabase.com/docs/blocks/memory

The `memory` block keeps what an assistant should remember between chats.
It has three parts:

* Core memory: a few small files under `/memories` that go into every
  prompt. The model reads and edits them with the commands of Anthropic's
  memory tool (`view`, `create`, `str_replace`, `insert`, `delete`,
  `rename`).
* Archival memory: a list of facts with embeddings, searched when the
  model asks.
* Recall: one embedding per chat message, so the assistant can find what a
  user said in an earlier chat. Temporary chats get none.

```bash
better-supabase sql add memory   # adds tenant, access and vector-search as well
```

| Table                   | Holds                                                                                          |
| ----------------------- | ---------------------------------------------------------------------------------------------- |
| `memories`              | Core files (`kind` `core`, with a `path`) and archival facts (`archival`), each with `version` |
| `ai_message_embeddings` | One embedding per chat message and user                                                        |

Every memory belongs to a namespace: the `user` (the default), an `agent`,
a `chat` or a `project` of that user, or the `organization`. A `project`
namespace names an ai-chat project with `projectId`, so every chat in the
project can share it. A user reads only their
own memories and the organization's; nobody else reads them, admins
included. Changing organization memory needs the manage permission.

| Permission | Lets a member                    | Default roles              |
| ---------- | -------------------------------- | -------------------------- |
| `ai.read`  | use memory in the tenant         | `owner`, `admin`, `member` |
| `ai.admin` | change the organization's memory | `owner`, `admin`           |

Rename the keys with `sql.modules.memory.permissions.read` and `.manage`.
The `maxContent` option (100,000 characters by default) limits one memory,
and `dimensions` and `type` set the embedding column as in
[knowledge](/docs/blocks/knowledge).

## Server [#server]

```ts title="lib/memory.ts"
import { embedWith } from "better-supabase/ai-sdk/embeddings";
import { createMemory, rpcTransport } from "better-supabase/blocks/memory";

export const memoryFor = (supabase: SupabaseClient, admin: SupabaseClient) =>
  createMemory({
    transport: rpcTransport(supabase),
    service: rpcTransport(admin),
    embedder: embedWith("openai/text-embedding-3-small"),
  });
```

| Group       | Methods                                                             |
| ----------- | ------------------------------------------------------------------- |
| `core`      | `view`, `write`, `strReplace`, `insert`, `remove`, `rename`, `list` |
| `archival`  | `save`, `search`, `list`, `forget`                                  |
| `recall`    | `index`, `search`                                                   |
| (top level) | `run`, `render`, `saveExtracted`, `embedPending`                    |

Each method takes the organization and an optional namespace, such as
`{ scope: "agent", agentId }` or `{ scope: "project", projectId }`. The service role passes `ownerId` to act for
a user.

## Core memory [#core-memory]

```ts
const reply = await memory
  .run(organizationId, {
    command: "create",
    path: "/memories/preferences.md",
    file_text: "Prefers metric units.\n",
  })
  .orThrow();
```

`run` takes one memory tool command and returns the text the model
expects back, such as `File created successfully at: /memories/preferences.md`.
Paths must stay under `/memories`. `str_replace` fails with
`MEMORY_NO_MATCH` or `MEMORY_AMBIGUOUS` unless the old text appears once,
and `write` with `expectedVersion` fails with `MEMORY_CONFLICT` when the
file changed. `render(organizationId)` returns the core files as delimited
text for the system prompt, cut at `maxRender` characters (8,000).

## Archival memory and recall [#archival-memory-and-recall]

```ts
await memory.archival
  .save(organizationId, "Works in the Lisbon office")
  .orThrow();
const hits = await memory.archival
  .search(organizationId, "where do they work")
  .orThrow();
```

Search ranks by embedding similarity and full text together.
`saveExtracted(organizationId, facts)` saves only facts that aren't close
to one already saved (similarity 0.92 by default, `dedupeThreshold`);
`forget(id, { supersededBy })` keeps the old fact but hides it.
`saveExtracted` embeds every fact in one embedder call.

`recall.index(message)` runs as the service role after a message is
stored; `recall.search(organizationId, query, { excludeChat })` returns the
caller's closest earlier messages. Facts saved without an embedder wait
for `embedPending()`, which embeds a batch in one call and writes it with
one `set_memory_embeddings` RPC. Each vector is written only while the
fact still has the text it was computed from, so a fact edited meanwhile
stays pending. A wrong number of vectors, mixed lengths or non-finite
numbers fail with the hint `EMBEDDING_INVALID`. Search sets
`hnsw.iterative_scan` for its query and puts the caller's value back.

## Documents for agent runtimes [#documents-for-agent-runtimes]

`memory.documents` stores versioned text documents under an opaque scope
key and a path, for runtimes that keep their own memory format, such as
eve's file memory. Only the service role reads and writes them.

```ts
const doc = await memory.documents.read("eve", "user:42/notes.md").orThrow();
await memory.documents
  .write("eve", "user:42/notes.md", "- Prefers short answers", {
    expectedVersion: doc?.version ?? null,
  })
  .orThrow();
```

A write names the version it read (`null` when the document must not
exist yet); a stale version fails with the hint `MEMORY_DOCUMENT_CONFLICT`,
so the caller reads again and retries. `expiresIn` sets a lifetime in
seconds, and `documents.purge()` deletes expired documents.
[eve](/docs/eve) uses the table for file memory and for its recall
records.

## AI SDK [#ai-sdk]

[`better-supabase/ai-sdk/memory`](/docs/ai-sdk/memory) gives the model the
memory tool, a recall tool, core memory in its instructions and a job that
extracts facts from a conversation.

# Notifications

> In-app notifications with per-recipient state, subject subscriptions, channel preferences, email and push deliveries, and realtime updates on a private topic.

Source: https://bettersupabase.com/docs/blocks/notifications

The `notifications` [SQL module](/docs/blocks/sql) stores one event per
change and one row per recipient, with read, dismissed and resolved state
each user controls. `createNotifications` from `better-supabase/blocks/notifications`
sends and reads them with typed data, and `useNotifications` from
`better-supabase/blocks/notifications/react` keeps a list current over Supabase Realtime.

```bash
pnpm better-supabase sql add notifications
```

Sending goes through `notify(jsonb)`, a `security definer` function, so a
client can't write notification rows directly or pick another user as the
actor. With the [access contract](/docs/blocks/access) installed, the sender
needs `notifications.send` in the organization and every recipient needs
`notifications.read`; the actor and non-members are left out. With the
[outbox](/docs/blocks/outbox), every notification also emits
`notification.created`.

## Sending [#sending]

Declare each type with a Standard Schema for its data. `send` validates the
data before it reaches the database:

```ts title="lib/notifications.ts"
import "server-only";

import {
  createNotifications,
  sqlTransport,
} from "better-supabase/blocks/notifications";
import { z } from "zod";

export const types = {
  "task.assigned": z.object({ title: z.string() }),
  "approval.requested": z.object({ amount: z.number() }),
};

export const notificationsFor = (claims: Record<string, unknown>) =>
  createNotifications({
    transport: sqlTransport(postgres.asUser(claims)),
    types,
    actionable: ["approval.requested"],
    events: betterSupabase.events,
    render: (item) => ({ title: `${item.type}: ${item.subject?.label ?? ""}` }),
  });
```

```ts
const ctx = await bs.context();
const notifications = notificationsFor(ctx.auth.claims);

await notifications
  .send("task.assigned", {
    tenant: organizationId,
    recipients: [assigneeId],
    subject: { type: "task", id: task.id, label: task.title },
    actionPath: `/tasks/${task.id}`,
    data: { title: task.title },
    key: `task-assigned-${task.id}-${assigneeId}`,
  })
  .orThrow();
```

`send` returns `{ id, recipients }`, the notification id and the user ids
that hold it after the watchers, the audience hook, preferences and the read
filter are applied, or `null` when nobody was left to notify. A composer
can report how many people it reached from `recipients.length`, and
`onSent` and the `notification.created` block event get the same list.
With a `key`, sending again in the same organization returns the first id,
so a retried request never notifies twice. In SQL, `send_notification(jsonb)`
returns the same `{ "id", "recipients" }` object and `notify(jsonb)` returns
only the id. `rpcTransport(supabase)` calls the
same functions over the Data API instead, when the block schema is exposed.

| Input          | Meaning                                                                                                                                   |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `recipients`   | User ids. Subject watchers and the `notification_audience` hook add more                                                                  |
| `subject`      | What it is about, `{ type, id, label? }`. Watchers of the subject get it too                                                              |
| `activity`     | `participating` (default) reaches every watcher; `all` reaches only watchers at level `all`                                               |
| `priority`     | One of `options.priorities` (`low`, `normal`, `high`, `urgent`). Defaults to `normal`                                                     |
| `channels`     | The channels to deliver on. Defaults to `options.channels`                                                                                |
| `includeActor` | Also notify the user who caused it                                                                                                        |
| `watchers`     | `false` leaves the subject's watchers out, for a composer that reaches named people only (a mention, an assignment); `ignore` still holds |
| `exclude`      | User ids left out after the watchers and the hook are added, such as the members a separate mention already reached                       |
| `resolved`     | Store it already resolved, for a record of something that needs no action                                                                 |
| `actorId`      | The actor when the service sends for a user. A signed-in sender is always the actor themselves                                            |

## Reading [#reading]

The same client reads the signed-in user's notifications. `render` runs at
read time, so text follows the reader's locale and later copy changes:

```ts
const items = await notifications
  .list({ tenant: organizationId, status: "unread", locale: "nl" })
  .orThrow();
const { unread, actionable } = await notifications
  .counts({ tenant: organizationId })
  .orThrow();
const one = await notifications.get(items[0].id).orThrow();
const { count, items: changed } = await notifications
  .markRead({ ids: [items[0].id] })
  .orThrow();
await notifications.dismiss([items[0].id]);
await notifications.resolve({
  type: "approval.requested",
  subject: { type: "invoice", id },
});
```

`get(id, { locale?, include? })` reads one of the caller's notifications by
its recipient row id, dismissed ones included, rendered like a list item, or
`null` when the caller has no such notification. It calls
`get_notification(id)`.

`get`, `list` and `page` parse each item's `data` with the schema of its type
in `types`, so `item.data` narrows on `item.type`: after checking
`item.type === "approval.requested"`, `item.data.amount` is a `number`. Data
the schema rejects makes the read fail with a `validation` error and the hint
`NOTIFICATION_DATA_INVALID`; a type that isn't in `types` keeps its data as
stored. `NotificationOf<typeof types>` is that item type.

`markRead`, `markUnread`, `dismiss` and `resolve` return `{ count, items }`:
how many rows changed, and the caller's own notifications as they are after
the change, so an app can update its state without reading them back. For
`resolve`, `count` covers every recipient and `items` holds only the
caller's own copies, which is empty for the service. The SQL functions
return the same object as `jsonb`, with each item in the shape that
`list_notifications` returns.

`list` pages with `cursor` and `limit` (50 by default, at most 200). Pass the
last item of the previous page as `cursor`: the block orders by `created_at`
and then `id`, so items created at the same instant are not skipped. It filters with `status` (`all`, `unread`, `read`, `unresolved`, `settled`),
`read`, `resolved`, `dismissed`, `types`, `subjectTypes` and `search`. `resolve` marks every recipient's copy of a type about one subject
as handled, for example when someone approves the invoice.

`search` matches text in the summary, the subject label and the type,
ignoring case; `%` and `_` in it are plain characters. `subjectTypes` keeps
notifications about subjects of the listed types. Every status except
`settled` leaves dismissed notifications out, and `settled` lists the
resolved and the dismissed ones, for an archive view.

`read`, `resolved` and `dismissed` are independent booleans that combine
with each other and with `status`. `read: false` keeps unread ones, and
`resolved: true` keeps resolved ones. `dismissed` defaults to `false`;
`true` lists only dismissed notifications and `null` lists both. `settled`
keeps its meaning and ignores `dismissed`.

| View              | Filters                            |
| ----------------- | ---------------------------------- |
| Unread and open   | `{ read: false, resolved: false }` |
| Handled           | `{ resolved: true }`               |
| Dismissed         | `{ dismissed: true }`              |
| Everything        | `{ dismissed: null }`              |
| Archive (settled) | `{ status: "settled" }`            |

Without a `resolved_at` column, `resolved: true` matches nothing and
`resolved: false` matches everything.

### Pages with a total [#pages-with-a-total]

An inbox with numbered pages uses `page`, which takes the same filters with
an `offset` instead of `before` and returns the matching total next to the
items:

```ts
const { items, total } = await notifications
  .page({ tenant: organizationId, search: "invoice", limit: 20, offset: 40 })
  .orThrow();
```

It calls `notification_page(tenant, status, types, subject_types, search,
max_items, skip, read, resolved, dismissed)`, which counts the matches in
the same query. `list_notifications` takes the same three filters after
`search`. `list` stays
the cheaper choice for an infinite scroll.

### Unread again and counts [#unread-again-and-counts]

`markUnread({ ids, tenant? })` clears the read time of the caller's own
notifications, so a reader can keep one for later. It returns the count and
the changed notifications and leaves dismissed notifications alone.

`counts()` returns `unread`, `actionable` (unresolved notifications of the
`actionable` types) and `actionableSubjects`, which counts each subject
once. Two reminders about the same invoice count as two in `actionable` and
as one in `actionableSubjects`, which suits a badge that counts open tasks.

### Hydrating a page [#hydrating-a-page]

`render` runs once per item. When it needs data from elsewhere, such as actor
names or subject titles, `hydrate` loads it for the whole page in one call,
and `render` reads the result from `context.hydrated`:

```ts
const notifications = createNotifications({
  transport,
  types,
  hydrate: async (items) => ({
    tasks: await loadTaskTitles(
      items.flatMap((item) => (item.subject ? [item.subject.id] : [])),
    ),
  }),
  render: (item, { locale, hydrated }) => ({
    title: t(locale, item.type, {
      task: hydrated?.tasks.get(item.subject?.id ?? ""),
    }),
  }),
});
```

With the [`profiles` module](/docs/blocks/profiles) installed alongside,
`list({ include: ["actor"] })` adds each item's `actor` (`id`, `username`,
`fullName`, `firstName`, `lastName`, `avatar` and `avatarPath`, those the
profiles table has) in one call to `notification_actors(ids)`, which returns
only actors of the caller's own notifications.

`avatarPath` is a Storage object path. With the `avatars` option the block
turns it into `actor.avatarUrl`: pass `{ url, bucket }` for a public bucket
(`<url>/storage/v1/object/public/<bucket>/<path>`), or a function of the
path for signed or transformed URLs:

```ts
const notifications = createNotifications({
  transport,
  types,
  avatars: { url: process.env.SUPABASE_URL!, bucket: "users" },
});
```

## Realtime [#realtime]

By default each new or changed recipient row is broadcast on the user's
private topic, `notifications:{userId}`. The payload carries ids only; the
client reloads through RLS. `useNotifications` joins the topic with the
user's token, reloads on each message and after a reconnect, and merges any
other sources you pass:

```tsx title="components/inbox.tsx"
"use client";

import { useNotifications } from "better-supabase/blocks/notifications/react";

export function Inbox({
  userId,
  organizationId,
}: {
  userId: string;
  organizationId: string;
}) {
  const { items, count } = useNotifications({
    topic: `organization:${organizationId}:notifications:${userId}`,
    load: () =>
      fetch(`/api/notifications?organization=${organizationId}`).then((res) =>
        res.json(),
      ),
  });
  return <Bell count={count} items={items ?? []} />;
}
```

| Option         | Default                  | Meaning                                                                  |
| -------------- | ------------------------ | ------------------------------------------------------------------------ |
| `realtime`     | `broadcast`              | `broadcast`, `changes` (adds the table to `supabase_realtime`) or `none` |
| `topic`        | `notifications:{userId}` | The topic template; `{tenantId}` adds the organization                   |
| `createdEvent` | `notification_created`   | The broadcast event for a new notification                               |
| `updatedEvent` | `notification_updated`   | The broadcast event for a read, dismissed or resolved one                |

The module adds a `realtime.messages` policy so each user can join only
topics that match the template with their own id.

## Subscriptions and preferences [#subscriptions-and-preferences]

Users watch a subject with `subscribe({ subject, level })`. Level `all` gets
routine activity too, `participating` only what involves them, and `ignore`
mutes the subject even when a sender names them. `null` removes the
subscription.

To make the author or an assignee follow a subject without overriding a
choice they made, pass `ifAbsent: true` with their `userId`:

```ts
await notifications
  .subscribe({
    subject: { type: "task", id: taskId },
    level: "participating",
    tenant: organizationId,
    userId: assigneeId,
    ifAbsent: true,
  })
  .orThrow();
```

It adds the level only when the member has none for the subject, so their
`ignore` or `all` stays. A signed-in sender with the send permission in the
tenant may do this for another member of it; without `ifAbsent`, only the
service sets another member's level. In SQL the flag is
`set_notification_subscription(..., member, if_absent => true)`.

The caller reads their own subscriptions with `subscriptions({ tenant?,
subject? })`, where `subject` is `{ type, id? }`, and their channel choices
with `preferences({ tenant? })`. With `tenant`, `preferences` returns the
choices for that organization and the ones that hold everywhere (`tenant`
null), so a settings page can show both. In SQL they are
`list_notification_subscriptions(tenant, subject_type, subject_id)` and
`list_notification_preferences(tenant)`, granted to `authenticated`.

`setPreference({ type, channel, enabled, tenant? })` turns a type (or `*`)
on or off on a channel. The most specific row wins: the organization and
type, the organization and `*`, everywhere and type, everywhere and `*`,
then `options.channelDefaults`. Without a default, `in_app` is on and every
other channel is off until the user turns it on, so nobody gets email they
did not ask for. The block resolves the preferences of all recipients in one
query, and `notify` refuses more than `options.maxRecipients` (1,000)
recipients with `NOTIFICATION_TOO_MANY_RECIPIENTS`; fan out larger audiences
from a job.

## Email, push and other channels [#email-push-and-other-channels]

Every channel except `in_app` gets a `pending` delivery row per recipient.
Pass a `NotificationChannel` per channel and call `deliver()` from a cron
route or a job, with a service connection:

```ts title="app/api/notifications/deliver/route.ts"
import "server-only";

import {
  createNotifications,
  sqlTransport,
} from "better-supabase/blocks/notifications";

import { types } from "@/lib/notifications";

const notifications = createNotifications({
  transport: sqlTransport(postgres.admin),
  types,
  channels: [
    {
      apiVersion: 1,
      name: "email",
      send: async ({ email, text, notification }) => {
        if (!email) return { status: "skipped" };
        const { id } = await resend.emails.send({
          to: email,
          subject: text?.title ?? notification.type,
        });
        return { provider: "resend", providerMessageId: id };
      },
    },
  ],
});

export async function GET() {
  return Response.json(await notifications.deliver({ budgetMs: 50_000 }));
}
```

`deliver` claims pending rows with `for update skip locked` and leases them
(`lease`, five minutes by default), so two runs never send the same message.
A channel that throws leaves the row pending until `next_attempt_at`, a
random time up to 30 seconds that doubles per attempt to at most an hour.
The database marks it `failed` after `maxAttempts` (5), and also when a
worker died while it held the last attempt. `testNotificationChannel` from
`better-supabase/testing` checks a channel against the contract.

Run `purge()` (`purge_notifications(older_than, batch)` in SQL) from a
scheduled job to delete notifications older than 90 days with their
recipients and deliveries, 10,000 per call.

## From outbox events [#from-outbox-events]

`notifications.sink({ map })` is an outbox sink that turns events into
notifications. `map` receives each CloudEvent, with the
`dev.better-supabase.` prefix stripped from its type, and returns a
notification or `null` to skip it:

```ts title="app/api/cron/notify/route.ts"
await outbox.relay(
  "notify",
  notifications.sink({
    map: (event) =>
      event.type === "organization.member_added"
        ? {
            type: "organization.joined",
            recipients: [String(event.data.userId)],
            data: { title: "You joined the organization" },
          }
        : null,
  }),
);
```

The notification's `key` defaults to the event id and its `tenant` to the
event's `partitionkey`, so a replayed batch sends nothing twice. A failed
send throws, and the relay retries the batch.

SQL modules notify through the notifications module in the same
transaction, not through this sink. The comments module already sends a
notification for every mention (`comment.mentioned`), so don't map that
event again.

## Extending it [#extending-it]

| Hook or event                   | Use                                                                                                                                      |
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `notification_audience` hook    | A SQL function that adds recipients, e.g. everyone with a role on the subject                                                            |
| `before_notification_send` hook | A SQL function that gets the notification as `jsonb` and raises to refuse it                                                             |
| `after_notify` hook             | A SQL function that runs in the same transaction after the rows are written                                                              |
| `hooks` option                  | Refuses, rewrites or observes any method in TypeScript, e.g. quiet hours on `send`; see [Extending blocks](/docs/extending/blocks#hooks) |
| `onSent` option                 | Runs after `send` stores a notification, e.g. to call Next's `updateTag`                                                                 |
| `notification.*` block events   | `created`, `delivered` and `failed` on `betterSupabase.events`                                                                           |
| `notifications.send` permission | Rename it with `sql.modules.notifications.permissions.send`                                                                              |

## Existing tables [#existing-tables]

The managed tables are `notification_events` (with `type`, `data` and
`actor_id`), `notification_recipients` (keyed on `user_id`),
`notification_deliveries`, `notification_subscriptions` and
`notification_preferences`, keyed on `organization_id`. Adopt yours and
rename what differs; map columns and tables you don't have to `null`:

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      notifications: {
        mode: "adopt",
        schema: "public",
        idType: "uuid",
        tables: { subscriptions: null },
        columns: {
          events: { key: null, actor: "actor_user_id", data: "metadata" },
          recipients: { user: "recipient_user_id", resolvedAt: null },
        },
        options: {
          topic: "organization:{tenantId}:notifications:{userId}",
          channels: ["in_app", "email"],
        },
      },
    },
  },
});
```

Without a key column, keyed sends derive the event id from the organization
and key instead: a UUIDv8 (RFC 9562) built from the MD5 of both, so the id
stays deterministic and passes strict validators such as `z.uuid()`. Events
stored by an earlier version under the plain MD5 id keep that id, and a
keyed send that matches one still returns it instead of notifying twice. A recipient with in-app off for a type still gets a row,
stored as dismissed, so other channels can deliver it.

## Error codes [#error-codes]

| `hint`                             | When                                                                |
| ---------------------------------- | ------------------------------------------------------------------- |
| `NOTIFICATION_TYPE_REQUIRED`       | `notify` without a type                                             |
| `NOTIFICATION_TYPE_UNKNOWN`        | `send` with a type that isn't in `types` (checked in TypeScript)    |
| `NOTIFICATION_DATA_INVALID`        | stored `data` that its type's schema rejects on a read (TypeScript) |
| `NOTIFICATION_FORBIDDEN`           | The sender lacks `notifications.send` in the organization           |
| `NOTIFICATION_PRIORITY_UNKNOWN`    | A priority that isn't in `options.priorities`                       |
| `NOTIFICATION_ACTIVITY_UNKNOWN`    | An `activity` other than `participating` or `all`                   |
| `NOTIFICATION_LEVEL_UNKNOWN`       | A subscription level other than `participating`, `all` or `ignore`  |
| `NOTIFICATION_STATUS_UNKNOWN`      | Completing a delivery with a status the block doesn't know          |
| `NOTIFICATION_TOO_MANY_RECIPIENTS` | More recipients than `options.maxRecipients`                        |

# Onboarding

> Onboarding checklists per user or per organization, with steps completed by hand or by an outbox event, and a useOnboarding hook.

Source: https://bettersupabase.com/docs/blocks/onboarding

The `onboarding` block tracks getting-started checklists. You declare each
checklist once in the app with `defineChecklist`; the database stores which
steps each user, or each organization, has done. A step completes when the
app calls `complete`, or on its own when the outbox records one of the event
types it lists.

```bash
better-supabase sql add onboarding   # adds tenant and access as well
```

| Table                 | Holds                                                                                                   |
| --------------------- | ------------------------------------------------------------------------------------------------------- |
| `onboarding_progress` | One row per completed step: `checklist`, `step`, the `user_id` or `organization_id`, and `completed_by` |

| Permission            | Lets a member                                       | Default roles              |
| --------------------- | --------------------------------------------------- | -------------------------- |
| `onboarding.read`     | see an organization checklist's progress            | `owner`, `admin`, `member` |
| `onboarding.complete` | complete or reset an organization checklist's steps | `owner`, `admin`           |

User checklists need no permission: each user reads and completes their own.

## Checklists [#checklists]

```ts title="src/onboarding.ts"
import { defineChecklist } from "better-supabase/blocks/onboarding";

export const gettingStarted = defineChecklist({
  id: "getting-started",
  scope: "organization",
  steps: [
    {
      id: "invite",
      title: "Invite a teammate",
      href: "/settings/members",
      events: ["organization.member_added"],
    },
    {
      id: "billing",
      title: "Add a payment method",
      href: "/settings/billing",
      events: ["billing.subscription_updated"],
    },
    { id: "project", title: "Create your first project" },
  ],
});
```

Pass the checklists to the module, so its functions know the steps and the
outbox trigger knows the event types:

```ts title="better-supabase.config.ts"
import { gettingStarted } from "./src/onboarding.ts";

export default defineConfig({
  sql: {
    modules: {
      onboarding: { options: { checklists: [gettingStarted] } },
    },
  },
});
```

Steps with `events` need the [outbox](/docs/blocks/outbox). An organization
step completes for the event's tenant; a user step completes for its actor.
Run `better-supabase sql add onboarding` again after you change a
checklist's steps.

## Progress [#progress]

```ts title="app/(app)/page.tsx"
import { rpcTransport } from "better-supabase/blocks/onboarding";
import { gettingStarted } from "@/onboarding";

const checklist = gettingStarted.connect({ transport: rpcTransport(supabase) });

const progress = await checklist.progress(organizationId).orThrow();
// { steps: [{ id, title, completed, completedAt, ... }], completed: 1, total: 3, done: false, next: { id: "billing", ... } }

await checklist.complete("project", organizationId);
await checklist.reset("project", organizationId);
```

A user checklist takes no organization: `checklist.progress()`.
`complete` returns `false` when the step was already done.

## In React [#in-react]

`useOnboarding` loads a checklist through the browser client and loads it
again after `complete` or `reset`:

```tsx title="components/getting-started.tsx"
"use client";

import { useOnboarding } from "better-supabase/blocks/onboarding/react";
import { gettingStarted } from "@/onboarding";

export function GettingStarted({ organizationId }: { organizationId: string }) {
  const { progress, complete } = useOnboarding(gettingStarted, {
    organizationId,
  });
  if (!progress || progress.done) return null;
  return (
    <ol>
      {progress.steps.map((step) => (
        <li key={step.id}>
          <a href={step.href}>{step.title}</a>
          {step.completed ? (
            " (done)"
          ) : (
            <button onClick={() => complete(step.id)}>Mark done</button>
          )}
        </li>
      ))}
    </ol>
  );
}
```

In Server Components, call `progress()` instead; the `react-server` build of
the hook throws.

## Functions [#functions]

| Function                                            | Granted to                      | Does                                           |
| --------------------------------------------------- | ------------------------------- | ---------------------------------------------- |
| `onboarding_progress(checklist, tenant)`            | `authenticated`, `service_role` | The completed steps of a checklist             |
| `complete_onboarding_step(checklist, step, tenant)` | `authenticated`, `service_role` | Marks a step done; `false` when it already was |
| `reset_onboarding_step(checklist, step, tenant)`    | `authenticated`, `service_role` | Marks a step not done                          |

Unknown steps raise `ONBOARDING_STEP_UNKNOWN`, a tenant on a user checklist
(or none on an organization one) `ONBOARDING_SCOPE`, and a missing
permission `ONBOARDING_FORBIDDEN`.

# Organizations and invitations

> Create organizations, manage members and roles, invite by email and switch the active organization, on tables you already have or tables the block creates.

Source: https://bettersupabase.com/docs/blocks/organizations

The `organizations` and `invitations` [SQL modules](/docs/blocks/sql) put
the rules for teams in Postgres: who may rename an organization, who may
invite whom and with which role, that an organization always keeps an owner,
and that an invitation is accepted once by the address it was sent to.
`better-supabase/blocks/organizations` calls those functions from TypeScript, returns a
`Result` for each call and emits [block events](/docs/extending/events).

```bash
pnpm better-supabase sql add organizations invitations
```

Both modules need `tenant` (the memberships table) and `access` (the
[access contract](/docs/blocks/access)), so `sql add` pulls those in too.
Permissions are checked with `member_can()`, so the same functions work with
the default roles, your own role and permission tables, an authorization
provider or your own functions.

## Functions [#functions]

| Function                                                                         | What it does                                                                                                                                                   |
| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `create_organization(attrs)`                                                     | creates the organization from `name`, `slug` and the `attributes` columns, and makes the caller owner                                                          |
| `update_organization(organization, attrs)`                                       | updates the columns present in `attrs` (`update`)                                                                                                              |
| `delete_organization(organization)`                                              | deletes it, sets `deleted_at` with `deleteMode: 'soft'`, or requests the deletion with `deleteMode: 'lifecycle'` (`delete`); missing with `deleteMode: 'none'` |
| `organization_slug_problem(value, except_organization)`                          | `invalid`, `reserved`, `taken` or null, for form validation                                                                                                    |
| `update_member_role(organization, member, role)`                                 | changes a member's role, up to the caller's own (`updateRole`)                                                                                                 |
| `remove_member(organization, member)` and `leave_organization(organization)`     | removes a member (`removeMember`), or the caller                                                                                                               |
| `transfer_ownership(organization, new_owner, former_role)`                       | makes another member owner and gives the caller `former_role` (`transferOwnership`)                                                                            |
| `suspend_member(organization, member)` and `resume_member(organization, member)` | suspends or resumes a member, who keeps their role (`suspendMember`); only with a membership `disabledAt` column                                               |
| `switch_organization(organization)`                                              | makes it the caller's active organization (see [Switching](#switching-organizations))                                                                          |
| `list_my_organizations()`                                                        | organizations the caller belongs to (`id`, `name`, `slug`, `role`, and `disabled_at` with a membership `disabledAt` column)                                    |
| `list_members(organization)`                                                     | members the caller may read (`members.read`), with `disabled_at` when the memberships table has the column                                                     |
| `list_organization_invitations(organization)`                                    | open invitations, when the invitations module is installed (`members.invite`)                                                                                  |
| `mark_used(organization)`                                                        | sets `last_used_at` on the caller's membership                                                                                                                 |
| `invite_member(tenant, invitee_email, invitee_role)`                             | creates an invitation and returns it with its token (`invite`)                                                                                                 |
| `resend_invitation(invitation_id)`                                               | issues a new token and expiry; the old token stops working                                                                                                     |
| `update_invitation(invitation_id, invitee_email, invitee_role, prefill)`         | changes an open, unexpired invitation's email, role or prefill (null keeps the value) with the `invite` checks; the token and expiry stay                      |
| `revoke_invitation(invitation_id)`                                               | revokes an open invitation (`revoke`)                                                                                                                          |
| `invitation_preview(token)`                                                      | status, email, role and organization for the accept page; callable without a session                                                                           |
| `accept_invitation(token)` and `decline_invitation(token)`                       | accepts for the signed-in user, or declines                                                                                                                    |
| `accept_invitation_by_id(id)` and `decline_invitation_by_id(id)`                 | the same by invitation id, for the invitee's signed-in session (an in-app inbox)                                                                               |

The name in parentheses is the block action whose permission the function
checks. `create_invitation(organization, email, role)` from 0.4 still works and
returns the token only.

Every check runs in the function, and the database keeps two rules even for
writes that bypass the functions. A deferred trigger refuses a commit that
leaves an organization without an owner (`ownerInvariant`), and a trigger on
the memberships table stops members from changing their own role or
assigning a role above their own. That guard checks only client writes (made
as `anon` or `authenticated`): the
service role, direct admin connections and `security definer` functions pass
it, so your own functions that write memberships (an invitation accept that
seats the invitee, a one-statement ownership transfer) work, and check their
own ceilings as the module's functions do.

When another trigger already guards an adopted memberships table, such as an
authorization provider's assignment rules, set `assignmentGuard:
"external"`: the module drops its own trigger, so a role change is checked
once and fails with one error vocabulary. The module's functions still
check before they write: `update_member_role` refuses a change to the
caller's own role (`ORGANIZATION_SELF_ROLE`) and needs `can_assign` for both
the member's current role and the new one (`ORGANIZATION_ROLE_CEILING`), and
`remove_member` needs it for the current role. Those checks hold even when
the external guard skips writes from `security definer` functions. A managed memberships table always keeps
the module's guard.

`transfer_ownership` promotes the new owner and demotes the calling owner in
one `update`, so a statement-level rule on the number of owners sees the
transfer as a whole, and it refuses a new owner
whose account is disabled (`ORGANIZATION_FORBIDDEN`) or whose membership is
suspended (`ORGANIZATION_MEMBER_SUSPENDED`).

## Suspending members [#suspending-members]

With a `disabledAt` column on the memberships table (managed tables have it;
an adopted one maps `sql.modules.tenant.columns.memberships.disabledAt`, see
[suspended memberships](/docs/blocks/access#suspended-memberships)),
`suspend_member(organization, member)` switches a member off in one
organization without removing them. They keep their role and their row, get
no permissions there, and `resume_member` gives everything back.

Both functions check the `suspendMember` action, which defaults to the
`removeMember` key (`members.remove`, or what
`sql.modules.organizations.permissions.removeMember` sets), and the role
ceiling: the caller must be able to assign the member's role
(`ORGANIZATION_ROLE_CEILING`). They refuse
the caller's own membership (`ORGANIZATION_SELF`), and `suspend_member`
refuses the last active owner (`ORGANIZATION_OWNER_REQUIRED`). A suspended
owner doesn't count as one, so the deferred owner check also refuses a commit
that leaves only suspended owners. Each returns `false` when nothing changed,
calls the `after_member_change` hook with `suspended` or `resumed`, writes an
`organization.member_suspended` or `organization.member_resumed` audit entry
when the audit module is installed, and emits the same event.

The role guard on the memberships table treats a client write that changes
`disabled_at` like a role change: nobody suspends or resumes themselves, or a
member above their own role.

From TypeScript, `suspendMember(organizationId, userId)` and
`resumeMember(organizationId, userId)` return whether the state changed, and
`members()` and `mine()` carry `disabledAt` (a `Temporal.Instant`) for a
suspended membership. To lock a user out of every organization and end their
sessions, suspend the account instead (see
[suspending an account](/docs/auth/account-deletion#suspending-an-account)).

The owner check locks the organization row, so two owners who leave at the
same time can't both succeed. When the block owns the organizations table, the
slug is required, and memberships, invitations and permission overrides
reference the organization with `on delete cascade`: deleting it deletes
them. Under `model: 'catalog'`, a role that a member still holds can't be
deleted (`on delete restrict`). The keys are added to tables that already
exist too; when older rows have no organization, the block adds the key
without validating them and logs a warning.

## From TypeScript [#from-typescript]

`createOrganizations` takes a transport that runs the functions as the user.
`mine()`, `members(organizationId)` and `invitations(organizationId)` list
the caller's organizations, an organization's members and its open
invitations.
`sqlTransport` uses a Postgres connection with the user's claims, and works
whatever schema the modules are in:

```ts title="app/organizations/actions.ts"
"use server";
import {
  createOrganizations,
  sqlTransport,
} from "better-supabase/blocks/organizations";
import { betterSupabase } from "@/lib/supabase/client";
import { postgres } from "@/lib/supabase/postgres";
import { bs } from "@/lib/supabase/server";
import { sendInvitationEmail } from "@/lib/email";

export async function inviteMember(organizationId: string, email: string) {
  const ctx = await bs.context();
  if (ctx.auth.kind !== "user") throw new Error("Sign in first");
  const organizations = createOrganizations({
    transport: sqlTransport(postgres.asUser(ctx.auth.claims)),
    events: betterSupabase.events,
    actorId: ctx.auth.user.id,
    canInvite: async () => (await seatsLeft(organizationId)) > 0,
    onInvite: ({ invitation, token }) =>
      sendInvitationEmail(invitation.email, `/invite/${token}`),
  });
  return organizations.invite({ organizationId, email, role: "member" });
}
```

`rpcTransport(ctx.supabase)` calls them over the Data API instead. Never add
`better_supabase` or a `sql.modules.<module>.schema` to `[api] schemas`: those
schemas hold internal helpers that policies call, and doctor reports exposing
them as BS312. Set `api: "api"` on the `organizations` and `invitations`
modules instead: `sql add` writes a `security invoker` entry point in `api` for
each function the modules grant to `authenticated`, with the same name and
arguments. Add `api` to `[api] schemas` and pass
`rpcTransport(ctx.supabase, { schema: "api" })`, or `schema: "api"` to
`createOrganizations`. With entry points in different schemas, pass
`schema: { organizations: "api", invitations: "app_api" }`.

```ts title="better-supabase.config.ts"
sql: {
  modules: {
    organizations: { api: "api" },
    invitations: { api: "api" },
  },
},
```

Each method returns an `AsyncResult`. Errors the block raises are `DbError`s
whose `hint` is a stable code, so a form can show the right message:

```ts
const result = await organizations.create({ name, slug });
if (!result.ok && result.error.hint === "ORGANIZATION_SLUG_TAKEN") {
  return { field: "slug", message: "That address is taken" };
}
```

| Option      | What it does                                                                                   |
| ----------- | ---------------------------------------------------------------------------------------------- |
| `transport` | `sqlTransport(client)`, `rpcTransport(client)` or your own `call(schema, fn, args)`            |
| `schema`    | the modules' schema, or one per module (`better_supabase`)                                     |
| `events`    | `betterSupabase.events`; each change emits `organization.*` or `invitation.*`                  |
| `actorId`   | the user acting, recorded on the events                                                        |
| `canInvite` | runs before `invite_member`; return `false` for seat limits or plans                           |
| `onInvite`  | runs after an invite or resend with the token, to send the email; when it throws, resend later |
| `mappers`   | your own error mapping, as in `betterSupabase.mapError()`                                      |

The token is only in the `onInvite` argument and the `invite` result. It is
never part of an event, and by default only its SHA-256 hash is stored.

The invitation in `onInvite` carries what an email needs without another
read. `organization` holds the id and `previewColumns`, `extra` holds the
keys an `invitation_preview_extra` hook adds (a role label, say, see
[existing tables](#existing-tables)), and, with the profiles module installed, `inviter`
holds the inviter's `id` and public profile fields (`username`, `fullName`,
`firstName`, `lastName` and `avatar`, as the profiles module maps them). An
email template can say who invited the person to which role:

```ts
onInvite: ({ invitation, token }) =>
  sendInvitationEmail(invitation.email, {
    link: `/invite/${token}`,
    inviter: String(invitation.inviter?.["fullName"] ?? "A teammate"),
    role: String(invitation.extra["roleLabel"] ?? invitation.role),
  }),
```

`invite_member`, `resend_invitation`, `update_invitation` and
`my_invitations` return the same keys in SQL. Without the profiles module,
`inviter` is `null`; return the inviter's name from the hook instead (it
receives the invitation id, and `invited_by` is on the row).

### Your own fields and hooks [#your-own-fields-and-hooks]

Columns from `options.attributes` and `options.extraColumns` come back from
`mine()` next to `id`, `name`, `slug` and `role`. Pass a Standard Schema for
them as `fields` to type them, parse them on read and validate `create` and
`update`, and `hooks` to refuse, rewrite or observe any method:

```ts
const organizations = createOrganizations({
  transport,
  fields: z.object({ plan: z.enum(["free", "pro"]) }),
  hooks: {
    create: {
      before: ([attrs, options]) => [{ plan: "free", ...attrs }, options],
    },
  },
});

const [first] = await organizations.mine().orThrow();
first?.plan; // "free" | "pro"
```

SQL hooks run inside the functions for every caller:
`before_organization_create(attrs jsonb, owner uuid)`,
`after_organization_create(organization, owner uuid)`,
`before_organization_update(organization, attrs jsonb)` and
`after_organization_update(organization, attrs jsonb)`.
[Extending blocks](/docs/extending/blocks) covers all of it.

## Switching organizations [#switching-organizations]

`switch_organization(organization)` checks the caller is a member and the organization
is active, then follows `sql.modules.access.activeTenant`:

| Source          | `switch_organization` writes                               | `refresh` |
| --------------- | ---------------------------------------------------------- | --------- |
| `claim`         | the claim (`claims.tenant`) into the user's `app_metadata` | `true`    |
| `profileColumn` | the organization id into that column of the user's profile | `false`   |
| `resolver`      | nothing: your resolver picks the tenant per request        | `false`   |

When `refresh` is `true`, refresh the session so the next access token
carries the new claim. If your access token hook builds the claim from
somewhere else, use the `profileColumn` or `resolver` source instead.

## Options [#options]

`sql.modules.organizations.options`:

| Option            | Default                             | What it does                                                                                                                                                                                                                                                          |
| ----------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `attributes`      | `[]`                                | extra columns `create_organization` and `update_organization` accept; a missing key keeps the column default                                                                                                                                                          |
| `extraColumns`    | none                                | column name to SQL type on a managed table, such as `{ plan: "text not null default 'free'" }`; added to the table and accepted like `attributes`, and both come back from `list_my_organizations` in `attributes jsonb`                                              |
| `ownerRole`       | `owner`                             | the creator's role                                                                                                                                                                                                                                                    |
| `formerOwnerRole` | `admin`                             | the previous owner's role after a transfer                                                                                                                                                                                                                            |
| `deleteMode`      | `hard`                              | `soft` sets `deleted_at` and keeps the memberships; `lifecycle` schedules the deletion with the data-lifecycle module's `request_organization_deletion` (grace period, then the purge job); `none` writes no `delete_organization`, so no entry point deletes at once |
| `ownerInvariant`  | `true`                              | an organization always keeps an owner                                                                                                                                                                                                                                 |
| `auditCategory`   | `organization`                      | the category of the `organization.deleted` audit entry; `audit_event` maps it through `sql.modules.audit.options.values` too, so an adopted log with its own categories can map `organization` there instead                                                          |
| `assignmentGuard` | `module`                            | `external` leaves role checks on an adopted memberships table to another trigger                                                                                                                                                                                      |
| `slugPattern`     | `^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$` | the slug format                                                                                                                                                                                                                                                       |
| `slugMinLength`   | `2`                                 | the shortest slug                                                                                                                                                                                                                                                     |
| `slugMaxLength`   | `48`                                | the longest slug                                                                                                                                                                                                                                                      |
| `reservedSlugs`   | `[]`                                | slugs nobody can take, checked with the `reserved-slugs` table when that module is installed                                                                                                                                                                          |
| `slugCitext`      | `false`                             | a `citext` slug column on a managed table                                                                                                                                                                                                                             |

`permissions.create` makes creating an organization a platform permission,
checked with `is_platform()`. Without it every signed-in user can create one.
`permissions.updatePlatform` and `permissions.deletePlatform` name platform
keys that let platform staff update or delete any organization without the
service role, next to the members who hold `update` or `delete` in it.
`permissions.updateRolePlatform` and `permissions.removeMemberPlatform` do the
same for `update_member_role` and `remove_member`: platform staff with that
key pass the permission check, and the role ceiling still applies, so the
access model's `can_assign` must allow them the roles they change.

`sql.modules.invitations.options` (`tokenStorage: "plain"` is migration-only: the
config accepts it in `mode: "adopt"` only, and doctor warns about it, BS314):

| Option                  | Default    | What it does                                                 |
| ----------------------- | ---------- | ------------------------------------------------------------ |
| `validFor`              | `7 days`   | how long an invitation is open                               |
| `maxValidFor`           | `30 days`  | the longest `valid_for` an invite or resend may ask for      |
| `prefill`               | `false`    | adds the `prefill` column (managed tables)                   |
| `tokenStorage`          | `sha256`   | `plain` stores the token itself; migration-only (adopt mode) |
| `tokenBytes`            | `24`       | the token's random bytes                                     |
| `requireConfirmedEmail` | `true`     | the user's address must be confirmed before accepting        |
| `previewColumns`        | `["name"]` | the organization columns `invitation_preview` returns        |
| `platformRoles`         | none       | the platform role table under the `provider` model           |

Accepting checks the inviter again: an inviter who lost the invite permission,
or a permission the role grants, can no longer bring someone in with it.

An invitation with a null organization is a platform invitation. It needs
`sql.modules.access.model: "catalog"` with a `platformAssignments` table, and lives in
its own `platform_invitations` table. The inviter needs the `invitePlatform`
permission (`platform.invite`) and every permission the platform role grants,
both when inviting and when the invitation is accepted. Managed catalog roles
have a `scope` column (`tenant` or `platform`), and neither kind of role is
assignable as the other. An adopted catalog maps `sql.modules.access.columns.roles.scope`
and names its values in `sql.modules.access.options.tenantRoleScope` and
`platformRoleScope`; an adopted invitations table that also holds platform
invitations (rows without an organization) maps `tables.platformInvitations`
to itself.

Under the `provider` model, name the table that holds platform role
assignments in `sql.modules.invitations.options.platformRoles`:

```ts title="better-supabase.config.ts"
invitations: {
  options: {
    platformRoles: {
      table: "public.user_roles",
      user: "user_id",
      role: "role_id",
      // The role column holds a roles table id; the role name is its key.
      // where names the platform roles when the table holds tenant roles too.
      through: {
        table: "public.roles",
        id: "id",
        column: "key",
        where: "{row}.scope = 'system'",
      },
      // Optional: who may assign which platform role. {role} is the name.
      canAssign: "authz.can_assign_platform_role({user}, {role})",
    },
  },
},
```

A platform invitation's role resolves only among the roles
`through.where` accepts (`{row}` is the roles row), so a tenant role's id or
key is refused with `INVITATION_ROLE_UNKNOWN`, when inviting and again at
accept. When `through.table` is also the tenant module's `roleThrough`
table, `where` is required there too (`roleThrough.where`, such as
`"{row}.scope = 'organization'"`), so organization invitations, accept and
`update_member_role` resolve only tenant roles; see
[membership roles stored as ids](/docs/blocks/access#membership-roles-stored-as-ids).
With `through.where`, a `bs_role_scope` trigger on `platformRoles.table` also
refuses a direct write of any other role, from the service role too, with
`PLATFORM_ROLE_SCOPE`.

The inviter needs `invitePlatform` through the provider's `isPlatform`. Without
`canAssign`, that permission alone lets them invite to any platform role.
Accepting inserts the assignment into the table. Platform invitations can
share the tenant invitations table (map `tables.platformInvitations` to the
same table): their rows have no tenant, the role is stored in the role
column's own type (a uuid through the roles table, say), and `prefill` is
written when the table has that column. When the provider has `isPlatformFor`,
accept checks the inviter again through it, never the invitee: a `canAssign`
with `{user}` runs with the inviter, a `canAssign` that reads the caller (no
`{user}`, such as `authz.can_assign_platform({role})`) is checked only when
they invite, unless `canAssignFor` gives the inviter's form, such as
`"authz.can_assign_platform_for({user}, {role})"`. Without `isPlatformFor`,
the inviter is checked when they invite, not again at accept.

### Accepting from the app [#accepting-from-the-app]

An in-app inbox can't hold the invitation token, since only its hash is
stored. `accept_invitation_by_id(invitation_id)` and
`decline_invitation_by_id(invitation_id)` do the same as the token functions
for the signed-in user whose confirmed email the invitation names; anyone
else gets `INVITATION_EMAIL_MISMATCH` on accept and `false` on decline.
`my_invitations()` lists the open invitations for the caller's confirmed
email, tenant and platform ones, without their tokens, so the inbox knows
what to show. Each one carries `created_at`, its `organization` (the id plus
`previewColumns`, null for a platform invitation), the `inviter` and the
keys an `invitation_preview_extra` hook adds, so the inbox needs no read
policy on the invitations table. In TypeScript,
`organizations.myInvitations()` returns them as `createdAt`, `organization`,
`inviter` and `extra`, and
`acceptInvitationById(id)` and `declineInvitationById(id)` answer them.

### Editing an invitation [#editing-an-invitation]

`update_invitation(invitation_id, invitee_email, invitee_role, prefill)`
changes an open invitation in place, so apps don't need a client update
policy on the invitations table. A null argument keeps the current value. It
makes the checks `invite_member` makes: the caller needs `invite` in the
organization (`invitePlatform` for a platform invitation), may assign both
the current and the new role (`INVITATION_ROLE_FORBIDDEN`), and a new address
must not belong to a member (`INVITATION_ALREADY_MEMBER`). Another open
invitation for the new address is replaced. The token and expiry stay, so
the link already sent keeps working: call `resend_invitation` to mail a new
link to a changed address. An expired invitation is refused with
`INVITATION_EXPIRED`, as accept refuses it; call `resend_invitation` first
to renew its expiry, then edit it. It returns the invitation without its token and
emits `invitation.updated`. In TypeScript:

```ts
await organizations.updateInvitation(invitationId, {
  role: "admin",
  prefill: { name: "Ada Lovelace" },
});
```

## Existing tables [#existing-tables]

Both modules support `adopt`, so they run over the tables you have. Map the
names and set the columns you don't have to `null`:

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      access: { model: "catalog" },
      tenant: {
        mode: "adopt",
        tables: { memberships: "public.organization_users" },
        columns: {
          memberships: { tenant: "organization_id", role: "role_id" },
        },
      },
      organizations: {
        mode: "adopt",
        tables: { organizations: "public.organizations" },
        columns: { organizations: { createdBy: null, deletedAt: null } },
        options: { attributes: ["logo_url"] },
      },
      invitations: {
        mode: "adopt",
        tables: { invitations: "public.organization_invitations" },
        columns: {
          invitations: {
            tenant: "organization_id",
            role: "role_id",
            tokenHash: "token",
          },
        },
        options: { tokenStorage: "plain" },
      },
    },
  },
});
```

Under the `catalog` access model, roles are ids in your roles table, and the
functions accept a role id or key. The `before_organization_create`,
`after_organization_create`, `after_member_change`, `before_invitation_create`
and `after_invitation_accept` [SQL hooks](/docs/extending/events#sql-hooks)
run your own checks and side effects in the same transaction.

`invitation_preview_extra(invitation uuid) returns jsonb` adds keys to what
`invitation_preview` returns, such as the role's display name or branding
from another table, and to every invitation `invite_member`,
`resend_invitation`, `update_invitation` and `my_invitations` return. Its keys are merged over the preview's, and it runs as
the module function's owner, since the preview is callable without a
session:

```sql
create function public.invitation_preview_extra(invitation uuid)
returns jsonb
language sql
stable
set search_path = ''
as $$
  select jsonb_build_object('roleLabel', r.label)
  from public.invitations i join public.roles r on r.id = i.role_id
  where i.id = invitation
$$;
```

In TypeScript, `previewInvitation(token)` returns the keys the hook added in
`extra` (`preview.extra.roleLabel`), next to the fixed fields.

## Errors [#errors]

| Code                                                                                 | When                                               |
| ------------------------------------------------------------------------------------ | -------------------------------------------------- |
| `ORGANIZATION_FORBIDDEN`                                                             | the caller lacks the permission                    |
| `ORGANIZATION_DISABLED`                                                              | the organization is disabled or deleted            |
| `ORGANIZATION_NOT_MEMBER`, `ORGANIZATION_NOT_FOUND`                                  | no such membership or organization                 |
| `ORGANIZATION_SELF`, `ORGANIZATION_SELF_ROLE`                                        | removing yourself, or changing your own role       |
| `ORGANIZATION_ROLE_CEILING`, `ORGANIZATION_ROLE_UNKNOWN`                             | a role above your own, or no such role             |
| `ORGANIZATION_OWNER_REQUIRED`                                                        | the change would leave no owner                    |
| `ORGANIZATION_SLUG_INVALID`, `ORGANIZATION_SLUG_RESERVED`, `ORGANIZATION_SLUG_TAKEN` | the slug can't be used                             |
| `INVITATION_FORBIDDEN`, `INVITATION_ROLE_FORBIDDEN`                                  | the caller may not invite, or not with that role   |
| `INVITATION_ROLE_UNKNOWN`, `INVITATION_ALREADY_MEMBER`                               | no such role, or the address is already a member   |
| `INVITATION_INVALID`                                                                 | unknown, accepted, declined or revoked invitation  |
| `INVITATION_EXPIRED`                                                                 | an open invitation past its expiry: resend it      |
| `INVITATION_VALIDITY`                                                                | `valid_for` is not positive or above `maxValidFor` |
| `INVITATION_SIGN_IN`, `INVITATION_EMAIL_MISMATCH`                                    | not signed in, or signed in with another address   |
| `INVITATION_EMAIL_UNCONFIRMED`                                                       | the address is not confirmed yet                   |
| `INVITATION_SELF`, `INVITATION_INVITER_REVOKED`                                      | your own invitation, or the inviter lost the right |
| `INVITATION_SCOPE_UNSUPPORTED`                                                       | a platform invitation without platform roles       |

`INVITATION_EXPIRED` and `INVITATION_INVALID` share the SQLSTATE `P0002`, so
code that checks only the SQLSTATE sees no change; check the `hint` to tell
an expired invitation, which `resend_invitation` renews, from one that is
gone. In TypeScript, `InvitationErrorHint` lists the invitation codes, and a
failed call carries one as `error.hint`:

```ts
const accepted = await organizations.acceptInvitation(token);
if (!accepted.ok && accepted.error.hint === "INVITATION_EXPIRED") {
  // Ask the organization to resend the invitation.
}
```

# Outbox

> Events written in the same transaction as the change, read by named consumers with their own cursor and relayed as CloudEvents.

Source: https://bettersupabase.com/docs/blocks/outbox

The `outbox` [SQL module](/docs/blocks/sql) stores events in a table in
the same transaction as the change that caused them. An event exists only
if that transaction commits, so a rolled-back write never notifies anyone
and a committed one is never lost. Named consumers read the events in order,
each with its own cursor, and `createOutbox` from `better-supabase/blocks/outbox`
relays them to any `EventSink` (an HTTP endpoint, a queue, a bus).

```bash
pnpm better-supabase sql add outbox
```

Once the module is installed, the other SQL modules write their events to
it: `organizations` emits `organization.created`, `organization.switched` and the member
events, and `support-sessions` emits `support.started` and
`support.ended`. Set
`sql.modules.<module>.events` to `false` to keep one module out of the outbox.

## Emitting from SQL [#emitting-from-sql]

`emit_event(type, payload, subject, tenant, key, source)` appends an event
and returns its id (a `uuid` on a managed table, as text). Call it from your
own functions and triggers:

```sql
perform better_supabase.emit_event(
  'invoice.paid',
  jsonb_build_object('invoice_id', new.id, 'amount', new.amount),
  subject => 'invoices/' || new.id,
  tenant => new.organization_id::text,
  key => 'invoice-paid-' || new.id
);
```

| Argument  | Meaning                                                                                  |
| --------- | ---------------------------------------------------------------------------------------- |
| `type`    | Any string, usually `noun.verb`. Consumers filter on it                                  |
| `payload` | The event data. `subject` defaults to `payload ->> 'subject'`                            |
| `tenant`  | The organization the event belongs to, relayed as the `partitionkey` extension           |
| `key`     | With a key, emitting the same key again for the same tenant returns the first event's id |
| `source`  | The module or function that wrote it, relayed as the `producer` extension                |

The signed-in user (`auth.uid()`) is stored as the actor. Only
`service_role` can call `emit_event` directly; the module's own functions are
`security definer` and call it for the user. Add roles with
`sql.modules.outbox.options.emitRoles`.

`track_events(table, type_prefix, tenant_column)` adds a row trigger that
emits `<prefix>.created`, `.updated` and `.deleted` with the row as payload
and `<table>/<id>` as subject. An update that changes no column emits
nothing:

```sql
select better_supabase.track_events('public.invoices', 'invoice', 'organization_id');
```

## Relaying events [#relaying-events]

Create the outbox client on a server connection, register each consumer
once, then relay it from a cron route or a worker:

```ts title="lib/outbox.ts"
import { createOutbox } from "better-supabase/blocks/outbox";

export const outbox = createOutbox(postgres.admin, {
  source: "https://crm.example.com",
  typePrefix: "com.example.crm",
});

await outbox.register("billing", {
  types: ["invoice.*", "organization.created"],
});
```

```ts title="app/api/outbox/route.ts"
import "server-only";

import { httpSink } from "better-supabase/events";

import { outbox } from "@/lib/outbox";

export const GET = outbox.relayRoute({
  secret: process.env.CRON_SECRET,
  consumers: {
    billing: httpSink(process.env.BILLING_EVENTS_URL!),
  },
  onError: (error, consumer) => console.error(consumer, error.message),
});
```

`relay(consumer, sink)` claims up to `batch` events (100 by default), sends
them as one `sink.send` call and moves the cursor past them, until none are
left or `budgetMs` runs out. When the sink throws, the cursor stays where it
was and the consumer backs off (2 seconds, doubling up to `maxBackoff`, 10
minutes by default), so delivery is at least once. The CloudEvent `id` is the
row id, so a receiver can drop repeats.

After a failure the consumer claims one event at a time, so one bad event
can't hold back a whole batch. When that single event fails `maxAttempts`
times (an option of `relay` and `consume`, 10 by default), it moves to the consumer's dead letters and the cursor
passes it; the result's `deadLettered` counts them. A success resets the
attempts.

```ts
const dead = await outbox.deadLetters("search").orThrow();
// [{ consumer, event, attempts, error, deadAt }]
```

Dead letters keep the event as it was, so you can fix the cause and emit it
again. `purge` deletes the ones older than its `olderThan`.

Each type gets `typePrefix` (`dev.better-supabase` by default) in front, the
payload is the `data` and the tenant is the `partitionkey`. The actor is not
sent: context attributes must not carry personal data, so put it in the
payload when receivers need it.

A claim leases the consumer to one worker for `lease` (one minute by
default), so two cron runs never send the same batch at the same time.
`relayRoute` checks the bearer secret and relays every consumer at the same
time within one budget, since each has its own cursor and lease.

### Consumers inside the app [#consumers-inside-the-app]

`consume(consumer, handler)` claims and acknowledges the same way, but hands
the handler the outbox rows instead of CloudEvents: `type`, `payload`,
`tenant`, `subject`, `key`, `actorId`, `position` and `createdAt`. Use it for
consumers in your own app, such as search indexing or notifications, that need
the actor. A handler that throws leaves the batch for the next run.

```ts
await outbox.consume("search", async (events) => {
  for (const event of events) {
    await index.update(event.subject, {
      by: event.actorId,
      at: event.createdAt,
    });
  }
});
```

`relayRoute` takes a handler in place of a sink too:
`consumers: { search: (events) => indexEvents(events) }`.

### Ordering and open transactions [#ordering-and-open-transactions]

Positions come from an identity column, which hands out numbers before
commit, so a transaction that started first can commit after a later one.
The claim holds back every event whose transaction id (`xid`) is not older
than the oldest running transaction (`pg_snapshot_xmin`), and orders and
tracks events by `(xid, position)`. An event that commits late therefore
sorts after the consumer's cursor and is delayed, never skipped. A
long-running transaction delays all consumers until it ends.

When none of the claimed events match a consumer's `types`, its cursor
still moves past the settled events, so a consumer of rare types doesn't
rescan the whole table. `outbox.unregister(consumer)` removes a consumer
you no longer relay, so purges stop waiting for it. `purge` with
`{ ignoreIdle: "7 days" }` also stops waiting for a consumer that hasn't
claimed for that long, without removing it.

### Into a queue [#into-a-queue]

To process events with retries, relay them into a [job queue](/docs/blocks/jobs)
with the event id as the dedupe key:

```ts
const toJobs = {
  send: async (events: readonly CloudEvent[]) => {
    for (const event of events) {
      await jobs.enqueue("events", event, { dedupeKey: event.id }).orThrow();
    }
  },
};
await outbox.relay("jobs", toJobs);
```

## History and retention [#history-and-retention]

`outbox.history({ subject: 'invoices/42' })` returns the kept events for one
subject, oldest first, for an activity feed or a debugging view. Filter by
`type` and page with `cursor` (a position) and `limit`.

`outbox.purge(olderThan, batch)` (SQL `purge_outbox(older_than, batch)`)
deletes up to `batch` events (10,000 by default) older than
`sql.modules.outbox.options.retention` (`30 days` by default), but never one that a
registered consumer hasn't passed (see `ignoreIdle` above). It returns how many it deleted; run it
again while that equals `batch`. Schedule it with the jobs schedules or
pg\_cron.

## Existing tables [#existing-tables]

Adopt an events table you already have and rename its columns. Adopt mode
adds the `xid` column (and its index) when the table lacks it, so `sql sync`
and `sql upgrade` don't ask you to declare it first. Map `xid` to
`null` to keep the table as it is: the claim then orders by position alone
and waits `settle` (5 seconds by default, and never zero) after an event's
`created_at`, which skips an event whose transaction commits later than
that. Other columns you don't have map to `null`.

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      outbox: {
        mode: "adopt",
        tables: { events: "public.domain_events" },
        idType: "uuid",
        columns: {
          events: { type: "kind", position: "seq", xid: null, key: null },
        },
        options: { settle: "2 seconds" },
      },
    },
  },
});
```

`position` must be a `bigint` that only grows. A managed table has a `uuid`
`id` and a separate `position` identity column; map both when you adopt a
table, or map them to the same column when its ids are a `bigint` sequence.

Options marked migration-only match an existing schema. The config accepts
them in `mode: "adopt"` only, and doctor warns about them (BS314) until you
remove them.

| Option          | Default                    | Meaning                                                                    |
| --------------- | -------------------------- | -------------------------------------------------------------------------- |
| `retention`     | `30 days`                  | Default age for `purge_outbox`                                             |
| `emitRoles`     | `["service_role"]`         | Roles allowed to call `emit_event` directly                                |
| `settle`        | `5 seconds`                | The wait before a claim reads an event, without `xid`; never zero          |
| `defaultSource` | none                       | The source `emit_event` stores when the caller passes none; migration-only |
| `blockSource`   | `better-supabase/{module}` | The source of events from SQL modules; migration-only                      |

## Error codes [#error-codes]

| `hint`                    | When                                          |
| ------------------------- | --------------------------------------------- |
| `OUTBOX_TYPE_REQUIRED`    | `emit_event` without a type                   |
| `OUTBOX_UNKNOWN_CONSUMER` | Claiming for a consumer that isn't registered |

# Profiles

> A profile row per user, created on sign-up from auth metadata, with a unique username, an email mirror and columns users can't change.

Source: https://bettersupabase.com/docs/blocks/profiles

The `profiles` [SQL module](/docs/blocks/sql) gives every user a profile
row. A trigger on `auth.users` creates it on sign-up from the user's
metadata, allocates a unique username and keeps the email in step with
`auth.users`. Users update their own name and avatar through the Data API,
while the email, the active organization and `disabled_at` stay with the
server.

```bash
pnpm better-supabase sql add profiles
```

## What sign-up fills in [#what-sign-up-fills-in]

`sync_profile(user_id)` runs after each insert into `auth.users`. When the
user has no profile yet, it inserts one:

| Column       | From                                                                                  |
| ------------ | ------------------------------------------------------------------------------------- |
| `full_name`  | metadata `full_name`, then `name`, then the first and last name joined                |
| `first_name` | metadata `first_name`, then `given_name`, then the first word of the full name        |
| `last_name`  | metadata `last_name`, then `family_name`, then the rest of the full name              |
| `avatar_url` | metadata `avatar_url`, then `picture`                                                 |
| `email`      | `auth.users.email`, and again after every change of address                           |
| `username`   | metadata `user_name`, `preferred_username` or `username`, else the email's local part |

`avatar_path` is never filled from metadata, and a `metadata` option that
names it is refused. It holds the Storage object path of an avatar the user
uploads (`<user id>/avatar.webp` in a public bucket), so a provider picture
in `avatar_url` never overwrites it. Users update it like their name, and
`update_my_profile` writes it. The Next.js example uploads to an `avatars`
bucket, saves the object path in `avatar_path` and shows `avatar_url` when
the user has no upload.

Usernames are lowercased, stripped to `a-z`, `0-9` and `_` (plus the `.`
or `-` of a `usernameFrom` separator), start with a letter, and get a number suffix while the name is reserved or another
profile has it (`ada`, then `ada1`). `allocate_username(base)` returns a
free one for a "pick a username" form. On a managed table, a check
constraint applies the same rules to names users pick themselves: the
length limits, the characters and `reservedUsernames` (`admin`, `api`,
`support`, `www` and similar by default).

After the module creates a profile, it calls your `after_profile_sync(user_id)`
[SQL hook](/docs/extending/events#sql-hooks) when it exists, to create rows
that hang off the profile. It doesn't call the hook for users who already
had a profile.

A failure while creating the profile, in the insert or in the hook, never
blocks the sign-up. The user gets an account without a profile, Postgres
logs a warning with the error, and `select better_supabase.backfill_profiles()`
as the service role creates the missing profiles once the cause is fixed.
Run it once after installing on a project that already has users.

## Who can change what [#who-can-change-what]

| Rule              | Default                                                                                                                                             |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Read              | your own profile; with `readPolicy: 'members'`, the public columns of everyone who shares an organization with you                                  |
| Update            | your own row, only the columns in `updatable`                                                                                                       |
| Service columns   | `email`, `disabled_at`, the active organization and team, `created_at`; a trigger refuses changes from the API roles with `PROFILE_COLUMN_READONLY` |
| Insert and delete | the service role and `auth.users` (rows are deleted with their user)                                                                                |

With `readPolicy: 'members'`, `authenticated` gets `select` on the public
columns only, so select them by name instead of `*`. `email`, the active
organization and team and `onboarding` stay private: read your own through
`better_supabase.my_profile()`, which returns your full row as jsonb.
`update_my_profile(attrs)` updates the `updatable` columns present in
`attrs`. `createProfiles` from `better-supabase/blocks/profiles` calls both:

```ts
import { createProfiles } from "better-supabase/blocks/profiles";

const profiles = createProfiles({ transport });
const row = await profiles.mine().orThrow();
await profiles.updateMine({ full_name: "Ada Lovelace" }).orThrow();
```

Pass a Standard Schema as `fields` to type your own columns (from
`extraColumns`, or any column of an adopted table): `mine()` parses the row
with it and `updateMine` validates the columns it sets. `hooks` refuses,
rewrites or observes `mine` and `updateMine`. In SQL,
`before_profile_update(attrs jsonb, user_id uuid)` runs before every update
and can raise to refuse it, and `after_profile_update(user_id uuid)` runs
after a change. See [Extending blocks](/docs/extending/blocks).

```ts
const profiles = createProfiles({
  transport,
  fields: z.object({ locale: z.enum(["en", "nl"]) }),
});
const profile = await profiles.mine().orThrow();
profile?.locale; // "en" | "nl"
```

On a managed table the guard also sets `updated_at`. An adopted table keeps
its own `updated_at` trigger: the guard neither checks nor writes the column.

Security definer functions, such as `switch_organization` from the
[organizations](/docs/blocks/organizations) module and the email mirror, write the
service columns. To disable users through the profile, point the access
contract at its column:

```ts title="better-supabase.config.ts"
sql: {
  modules: {
    access: { disabled: { user: "better_supabase.profiles.disabled_at" } },
    profiles: {},
  },
},
```

## Options [#options]

`sql.modules.profiles.options`:

| Option              | Default                                               | What it does                                                                                                     |
| ------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `metadata`          | the keys in the table above                           | metadata key to column name; replaces the defaults                                                               |
| `splitName`         | `true`                                                | fills first and last name from a full name                                                                       |
| `username`          | `true`                                                | allocates a username on sign-up                                                                                  |
| `usernameFrom`      | `["user_name", "preferred_username", "username"]`     | metadata keys tried before the email, or `{ names, separator }` and `{ columns, separator }` entries (see below) |
| `usernameMinLength` | `3`                                                   | shorter names become `user`                                                                                      |
| `usernameMaxLength` | `32`                                                  | the longest name, suffix included                                                                                |
| `reservedUsernames` | `admin`, `api`, `support`, `www` and more             | names nobody gets; allocation adds a suffix                                                                      |
| `extraColumns`      | none                                                  | column name to SQL type on a managed table, such as `{ locale: "text not null default 'en'" }`                   |
| `updatable`         | name, avatar, username, onboarding and `extraColumns` | the columns users can update                                                                                     |
| `columnGrants`      | `true` when managed                                   | revokes `update` and grants it on `updatable` only                                                               |
| `serviceColumns`    | see above                                             | the columns only the service changes; `[]` drops the guard. `updated_at` is never guarded                        |
| `readPolicy`        | `self`                                                | `members` also shows profiles of people in the same organizations (needs `tenant`); see below                    |
| `syncTrigger`       | `true`                                                | `false` leaves sign-up to your own trigger, which calls `sync_profile`                                           |
| `mirrorEmail`       | `true`                                                | keeps `email` equal to `auth.users.email`                                                                        |

An entry of `usernameFrom` can join several metadata keys when all of them
are set, so a "first\_last" convention is a setting:

```ts
profiles: {
  options: {
    usernameFrom: [
      { names: ["first_name", "last_name"], separator: "_" },
      "user_name",
    ],
  },
},
```

Ada Lovelace becomes `ada_lovelace`; a user without a last name falls back to
`user_name`, then the email. `allocate_username` still lowercases, strips and
suffixes the result, and keeps a `.` or `-` separator, which the username
check then also accepts.

`{ columns, separator }` joins the values the profile's own columns get
instead, after `metadata` and `splitName`. A sign-up that only sends
`full_name: "Grace Hopper"` becomes `grace.hopper`:

```ts
profiles: {
  options: {
    usernameFrom: [{ columns: ["first_name", "last_name"], separator: "." }],
  },
},
```

`readPolicy` also takes `{ members, platform }`. `platform` is a permission
key that `is_platform()` checks, so platform staff read every profile, which
admin consoles need. It writes its own policy, `bs_profiles_platform_read`,
in managed and adopt mode, and pulls in the `access` module. Column grants
still apply, so the private columns stay behind them.

```ts
profiles: { options: { readPolicy: { members: true, platform: "platform.user.read" } } },
```

## Existing profiles [#existing-profiles]

With `mode: 'adopt'` the module writes its functions and triggers over your
table and leaves its columns, policies and grants alone. Map the key and
the columns you have, and set the others to `null`:

```ts title="better-supabase.config.ts"
sql: {
  modules: {
    profiles: {
      mode: "adopt",
      tables: { profiles: "public.profiles" },
      columns: {
        profiles: {
          key: "user_id",
          fullName: null,
          avatar: null,
          avatarPath: "avatar_path",
          activeTenant: "active_organization_id",
          onboarding: null,
        },
      },
      hooks: {
        schema: "public",
        functions: { after_profile_sync: "create_contact_profile" },
      },
      options: { syncTrigger: false, usernameMaxLength: 30 },
    },
  },
},
```

If a trigger of yours already creates profiles (`handle_new_user`),
installing warns about it. Keep it and set `syncTrigger: false`, calling
`better_supabase.sync_profile(new.id)` from it, or drop it and move its
extra work into `after_profile_sync`. With `mode: 'custom'` the module
writes nothing and you provide `sync_profile` and `backfill_profiles`.

An adopted table has no `avatarPath` column until you map one, so a table
without it keeps working.

Avatar and logo uploads use the [`avatarBucket` and `organizationLogoBucket`
presets](/docs/platform/storage#avatars-and-organization-logos).

# Push notifications

> Push tokens per device with RLS, Expo device registration, and a sender for the Expo Push API that prunes dead tokens.

Source: https://bettersupabase.com/docs/blocks/push

The `push` block stores the push tokens of each user's devices and sends to
them. The app registers a device after sign-in and removes it on sign-out;
the server sends through the Expo Push API and deletes the tokens Expo no
longer reaches. With the [notifications](/docs/blocks/notifications) block,
it is a delivery channel.

```bash
better-supabase sql add push
```

| Table          | Holds                                                                                                                                                     |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `push_devices` | One row per token: `user_id`, `token`, `platform` (`ios`, `android`, `web`), `provider` (`expo` by default), `device_name`, `app_version`, `last_seen_at` |

Each user reads and deletes their own devices; nobody else can. A token
belongs to one user: when a device signs in as someone else, registering
moves the token to them. Only the service role reads every user's tokens
(`push_tokens_for`) and prunes them (`prune_push_tokens`). With the
[outbox](/docs/blocks/outbox), the functions record `push.device_registered`
and `push.device_unregistered`.

## Register the device [#register-the-device]

```tsx title="app/_layout.tsx"
import * as Notifications from "expo-notifications";
import Constants from "expo-constants";
import { Platform } from "react-native";
import {
  createPushDevices,
  registerDevice,
  rpcTransport,
  unregisterOnSignOut,
} from "better-supabase/blocks/push";

const devices = createPushDevices({ transport: rpcTransport(supabase) });
let token: string | undefined;

export async function onSignedIn() {
  const device = await registerDevice({
    notifications: Notifications,
    devices,
    platform: Platform.OS,
    projectId: Constants.easConfig?.projectId,
    appVersion: Constants.expoConfig?.version,
  }).orThrow();
  token = device?.token;
}

export const signOut = unregisterOnSignOut({
  signOut: () => supabase.auth.signOut(),
  devices,
  token: () => token,
});
```

`registerDevice` asks for permission when it isn't granted yet (pass
`request: false` to only check), reads the Expo push token and registers it
for the signed-in user. It resolves with `null` when the user says no or the
platform has no push. Call it after every sign-in: a known token only
refreshes `last_seen_at`.

`unregisterOnSignOut` returns a sign-out function that removes the token
first, while the session can still call the database. A failed removal
doesn't block the sign-out.

## Send [#send]

```ts title="server/push.ts"
import { sqlTransport } from "better-supabase/blocks/push";
import { createPushDevices, expoPush } from "better-supabase/blocks/push";

const devices = createPushDevices({ transport: sqlTransport(postgres.admin) });
const push = expoPush({ accessToken: env.EXPO_ACCESS_TOKEN, devices });

const targets = await devices.tokensFor([userId]).orThrow();
const tickets = await push.send(
  targets.map((target) => target.token),
  {
    title: "New comment",
    body: "Ada replied to your post",
    data: { path: "/posts/1" },
  },
);
```

`expoPush` uses `fetch` only, so it runs on every runtime. `send` posts 100
messages per request and returns one ticket per token, in order. A ticket
with `DeviceNotRegistered` deletes its token right away. Expo reports most
failures later, in receipts: about 15 minutes after sending, pass the
tickets to `checkReceipts`, which prunes the devices Expo no longer reaches
and returns what failed. `accessToken` is needed only when the Expo project
turns on push security; it is a server-only key.

## As a notifications channel [#as-a-notifications-channel]

```ts title="server/notifications.ts"
import { createNotifications } from "better-supabase/blocks/notifications";
import { expoPushChannel } from "better-supabase/blocks/push";

export const notifications = createNotifications({
  transport: sqlTransport(postgres.admin),
  types,
  render,
  channels: [expoPushChannel({ devices, push })],
});
```

`expoPushChannel` sends each `push` delivery to the recipient's Expo
devices, with the rendered title and body and the notification's id, type
and `actionPath` in `data`. A recipient without devices is `skipped`. When
Expo rejects every message for a reason other than an unregistered device,
the channel throws, and `notifications.deliver()` retries the delivery.
Pass `message` to build the push yourself, and `onTickets` to keep the
tickets for `checkReceipts`.

# Settings

> Per-user and per-organization settings with a Standard Schema per key, defaults, typed get and set, and matching pg_jsonschema checks.

Source: https://bettersupabase.com/docs/blocks/settings

The `settings` block stores preferences as key-value rows: one table for each
user's own settings and one for organization settings. You declare the keys
once with a Standard Schema each (Zod, Valibot, ArkType), and the block gives
you typed `get`, `set` and `reset` that validate before they write.

```bash
better-supabase sql add settings   # adds tenant and access as well
```

| Table                   | Key                      | Who reads                    | Who writes                     |
| ----------------------- | ------------------------ | ---------------------------- | ------------------------------ |
| `user_settings`         | `(user_id, key)`         | the user                     | the user                       |
| `organization_settings` | `(organization_id, key)` | members with `settings.read` | members with `settings.update` |
| `platform_settings`     | `key`                    | each key's `read` rule       | each key's platform permission |

The default roles give `admin` both permissions and `member` the first.
Rename them with `sql.modules.settings.permissions`. Both tables keep
`updated_by` and `updated_at`, and the functions run as the caller, so the
row level security policies decide every read and write.

## Declaring settings [#declaring-settings]

```ts title="src/lib/settings.ts"
import { toStandardJsonSchema } from "@valibot/to-json-schema";
import { defineSettings } from "better-supabase/blocks/settings";
import * as v from "valibot";

export const settings = defineSettings({
  user: {
    theme: {
      schema: toStandardJsonSchema(v.picklist(["light", "dark", "system"])),
      default: "system",
    },
    digest: { schema: v.object({ weekly: v.boolean() }) },
  },
  organization: {
    defaultRole: {
      schema: toStandardJsonSchema(v.picklist(["member", "viewer"])),
      default: "member",
    },
  },
});
```

A key without a `default` reads as `undefined` until it is set. A stored
value that no longer matches its schema (after you narrow an enum, for
example) also reads as the default, so old rows never break a page.

## Reading and writing [#reading-and-writing]

```ts title="app/settings/actions.ts"
import { rpcTransport } from "better-supabase/blocks/settings";

import { settings } from "@/lib/settings";

const client = settings.connect({
  transport: rpcTransport(supabase, { schema: "api" }),
});

const theme = await client.user.get("theme").orThrow(); // "light" | "dark" | "system"
await client.user.set("theme", "dark").orThrow();
await client.user.reset("theme"); // back to the default

const all = await client.organization.get(organizationId).orThrow();
await client.organization.set(organizationId, "defaultRole", "viewer");
```

`set` returns a `validation` error with the schema's issues when the value
doesn't match, and an `invalid_input` error for a key you didn't declare;
neither reaches the database. Use `sqlTransport(postgres.asUser(claims))`
instead of `rpcTransport` to call the functions over Postgres. With
`rpcTransport`, set `sql.modules.settings.api` to an exposed schema such as
`api` and pass `rpcTransport(supabase, { schema: "api" })`; see
[SQL modules](/docs/blocks/sql#calling-a-module-over-the-data-api).

## Platform settings [#platform-settings]

Settings for the whole product, such as fee rates, routing rules or an
announcement text an admin console edits, go in the `platform` scope. Each
key can name the platform permission (`is_platform`) that may change it and
who reads it: `public` (also signed out), `authenticated` (the default) or
`staff` (holders of the key's permission). Keys guarded by different
permissions share one table:

```ts title="lib/platform-settings.ts"
export const platformSettings = defineSettings({
  platform: {
    invoiceFeePercent: {
      schema: v.pipe(v.number(), v.minValue(0), v.maxValue(10)),
      default: 0,
      permission: "platform.billing.manage",
      read: "public",
    },
    aiDefaults: {
      schema: v.object({ model: v.string() }),
      permission: "platform.ai.manage",
      read: "staff",
    },
  },
});
```

Pass the definition as `sql.modules.settings.options.schemas`, so the
module writes the permission and read rule of each key into the policies.
When `schemas` lists platform keys, only those keys can be written: a key
the config doesn't list is refused, so a typo or a stale key never lands in
`platform_settings`. Rows that exist without a listed key are read with
`options.platform.permission` (default `settings.manage`, renamed with
`permissions.platform`) and `options.platform.read`. Without listed keys,
any key can be written with that permission.

```ts
const admin = platformSettings.connect({
  transport: sqlTransport(postgres.asUser(claims)),
});
await admin.platform.set("invoiceFeePercent", 1.5).orThrow();
const fee = await admin.platform.get("invoiceFeePercent").orThrow();
```

A key a caller may not read comes back as its default, and a write without
the key's permission fails with `forbidden`.

## Checks in the database [#checks-in-the-database]

Pass the definition to the module, and keys whose schema implements
[Standard JSON Schema](https://standardschema.dev/json-schema) get a
`pg_jsonschema` check, so writes that skip TypeScript are held to the same
shape. Zod 4 schemas implement it directly; wrap Valibot schemas in
`toStandardJsonSchema`:

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

import { settings } from "./src/lib/settings.ts";

export default defineConfig({
  sql: {
    modules: {
      settings: { options: { schemas: settings } },
    },
  },
});
```

```bash
better-supabase sql add jsonb-schemas settings
```

The checks need the `jsonb-schemas` module, which installs `pg_jsonschema`.
Each one is named `bs_json_value_<key>` and applies only to rows with that
key. `options.schemas` also takes plain `{ user, organization }` maps of key to
JSON Schema. Keys whose schema has no JSON Schema (`digest` above) are
validated in TypeScript only.

## Options [#options]

| Option     | Default                                                    | What it does                                                                   |
| ---------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `schemas`  | none                                                       | A `defineSettings()` result or `{ user, organization, platform }` JSON Schemas |
| `platform` | `{ permission: "settings.manage", read: "authenticated" }` | The permission and read rule of platform keys that don't set their own         |

# SQL modules

> Idempotent SQL modules for the database work every app repeats, written into your declarative schema.

Source: https://bettersupabase.com/docs/blocks/sql

`better-supabase sql add` writes SQL modules into `supabase/schemas`, and
`supabase db schema declarative sync` turns them into a migration. The files are plain SQL that
you can read and commit, so nothing runs behind your back. Every module can be
re-run safely, and it keeps everything it creates in the `better_supabase`
schema. `sql add` renders the new modules next to the ones `sql.modules`
already lists, so a module sees what is installed (billing's foreign key to
the organizations table, for example), and writes only the named modules and
the ones they need.

```bash
better-supabase sql list
better-supabase sql add audit jobs pgtap
supabase db schema declarative sync -f better_supabase_block
better-supabase sql data
```

The extensions a module's schema file creates (`pgmq` for `jobs`,
`pg_jsonschema` for `jsonb-schemas`, `vector` for `vector-search`) have to
exist before the schema migration applies, because objects in it use them: a
check constraint on `jsonb_matches_schema`, a pgmq queue. A schema diff can
leave them out of its migration, so `sql add`, `sql sync` and `sql upgrade`
write the ones no migration creates yet into
`supabase/migrations/<stamp>_better_supabase_extensions.sql`, stamped before
the schema migration you create next. A migration you wrote yourself that
creates the extension counts too, so the CLI writes nothing for it.
`sql data` refuses to write the data migration while a module extension is
missing from every earlier migration; run `sql sync`, then create the schema
migration again after the extensions migration.

A schema diff only captures objects, so each module's rows and settings (its
`better_supabase.modules` row, the reserved slugs, the rate-limit role
setting) go to a second file in `supabase/better-supabase-data/`, outside the schema folder:
pg-delta loads every file under `supabase/schemas`, nested folders included,
and refuses a table that has rows afterwards. When `sql.dir` is a folder
inside the schema folder (`supabase/schemas/block`), the data files still go
next to the schema folder, and `declarative_schema_path` in
`supabase/config.toml` moves both. `better-supabase sql data` writes those files into one
migration, stamped after the newest one so it runs after the schema migration.
Its statements are idempotent, and it writes nothing when the latest data
migration already has them. `sql add` and `sql sync` say when to run it.

Event triggers belong to no schema, so a diff limited to some schemas
(`supabase db schema declarative sync -s public,better_supabase`) leaves
them out of its migration. Run the sync without `-s` when you use `audit`
(`bs_audit_forget_dropped`) or `ensure-rls` (`bs_ensure_rls`). Their data
files repeat the event triggers (`drop event trigger if exists`, then
`create event trigger`), so the `sql data` migration creates them even when
the schema migration lacks them, and
[doctor BS323](/docs/cli/doctor#bs323) reports a module event trigger that
the database, or every migration, lacks.

The sync uses pg-delta, the Supabase CLI's diff engine (2.119 or later), which
orders the schema files by dependency and carries grants, comments and
`security_invoker` on views into the migration. It needs
`[experimental.pgdelta] enabled = true` in `supabase/config.toml`: projects
created by `supabase init` have it, and `better-supabase init` adds it to an
existing `config.toml`.

### Legacy migra engine [#legacy-migra-engine]

Without that table, the Supabase CLI diffs with migra, and
[doctor](/docs/cli/doctor#bs316) reports BS316. `sql add` and doctor still
name the migra command: `supabase db diff -f better_supabase_block`, run while
the stack is stopped. When `supabase/config.toml` lists
`[db.migrations] schema_paths`, migra reads only the files those entries
match, so `sql add` names any module file no entry matches and prints the
lines to add before the schemas that call their functions. Supabase's
[switch guide](https://supabase.com/docs/guides/local-development/declarative-database-schemas#switching-to-pg-delta)
lists the steps to move to pg-delta.

## Modules [#modules]

| Module               | What it adds                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `updated-at`         | `track_updated_at(table)` keeps an `updated_at` column current                                                                                                                                                                                                                                                                                                                                                                                                               |
| `actor`              | `track_actor(table)` fills `created_by` and `updated_by` from `auth.uid()`; `impersonated_by => 'column'` also stamps an [impersonating](/docs/auth/impersonation) admin                                                                                                                                                                                                                                                                                                     |
| `audit`              | `audit(table, ignore => '{updated_at}')` records inserts, updates and deletes in `audit_events`, keyed by the primary key, with the changed columns, the tenant column from `plugins.tenant.column` and any impersonating admin                                                                                                                                                                                                                                              |
| `tenant`             | `memberships`, `current_tenant_id()`, `member_organization_ids(roles)` and `has_organization_role(organization, roles)` for RLS policies, and `membership_claims(user)` for the access token hook                                                                                                                                                                                                                                                                            |
| `access`             | `can(scope, id, permission)`, `tenant_ids_with(permission)` and `is_platform(permission)` over roles, your own tables, an authorization provider or your functions. See [Access contract](/docs/blocks/access)                                                                                                                                                                                                                                                               |
| `invitations`        | `invite_member(tenant, email, role)` returns a single-use token, and only its hash is stored; `accept_invitation(token)` checks the signed-in user's confirmed email. See [Organizations](/docs/blocks/organizations)                                                                                                                                                                                                                                                        |
| `reserved-slugs`     | `track_slug(table)` rejects reserved words (`admin`, `api`, ...) and malformed slugs, also for `service_role` writes; `options.minLength` and `options.maxLength` (1 and 63 by default) bound the length, and `options.slugs` adds the app's own words, such as its route names, to the data file                                                                                                                                                                            |
| `jobs`               | Jobs on Supabase Queues (pgmq) or a table: leases, retries with backoff, dead letters, dedupe keys and schedules (pg\_cron or the drain route). Used by [`better-supabase/blocks/jobs`](/docs/blocks/jobs)                                                                                                                                                                                                                                                                   |
| `idempotency`        | Stores `Idempotency-Key` results so retried requests replay the first response                                                                                                                                                                                                                                                                                                                                                                                               |
| `webhook-inbox`      | Stores verified webhooks once and hands them to a worker                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `realtime-tables`    | Payload-free change broadcasts for [live queries](/docs/frontend/live-queries), registered from `realtime.tables`                                                                                                                                                                                                                                                                                                                                                            |
| `jsonb-schemas`      | pg\_jsonschema `check` constraints for jsonb columns with a `schema` in the `json` config (see below)                                                                                                                                                                                                                                                                                                                                                                        |
| `pgtap`              | Test helpers for `supabase test db`. See [Testing](/docs/testing)                                                                                                                                                                                                                                                                                                                                                                                                            |
| `grants`             | The complete Data API privileges of the tables and functions in `expose`, for `anon`, `authenticated` and `service_role`, or grants derived from your policies with `options.fromPolicies`. See [Data API grants](/docs/guides/data-api-grants)                                                                                                                                                                                                                              |
| `read-sets`          | One `stable`, `security invoker` function per read set in `readSets`, written by `gen`. See [Read sets](/docs/repository/read-sets)                                                                                                                                                                                                                                                                                                                                          |
| `mfa`                | `mfa_satisfied()` for restrictive policies that require `aal2` once a user has a verified factor. See [MFA and SSO](/docs/auth/mfa-sso)                                                                                                                                                                                                                                                                                                                                      |
| `sessions`           | `session_active()` for restrictive policies that reject tokens whose session was signed out or expired, or whose user was banned or deleted; `options.policies` writes the policy on every table (`options.exclude` skips some), and `last_sign_ins(user_ids)` reads many users' last sign-in at once for the service role. See [Ended sessions](/docs/auth/account-deletion#ended-sessions)                                                                                 |
| `entitlements`       | `tenant_entitlements(tenant)`, `has_entitlement(tenant, key)` and `feature_claims(user)` over the Stripe Sync Engine. See [Entitlements](/docs/blocks/entitlements)                                                                                                                                                                                                                                                                                                          |
| `vector-search`      | `search_<table>(query, k)` per table in `vectorSearch`, for `db.$search`. See [Vector search](/docs/blocks/vector-search)                                                                                                                                                                                                                                                                                                                                                    |
| `rate-limit`         | `set_rate_limit(scope, max, period)` and `check_request()`, a `pgrst.db_pre_request` hook that answers writes over the limit with 429. See [below](#rate-limiting-writes)                                                                                                                                                                                                                                                                                                    |
| `support-sessions`   | Support sessions: `start_support_session(target, reason)` checks `is_platform`, records the session and audits it, for the [support mode](/docs/auth/impersonation)                                                                                                                                                                                                                                                                                                          |
| `organizations`      | `create_organization(attrs)`, member roles, ownership transfer, `switch_organization(organization)`, `list_my_organizations()`, `list_members(organization)` and an owner check on commit. See [Organizations](/docs/blocks/organizations)                                                                                                                                                                                                                                   |
| `profiles`           | A profile per user from sign-up metadata, with a unique username, an email mirror, `my_profile()`, `update_my_profile(attrs)`, column grants and service-owned columns. See [Profiles](/docs/blocks/profiles)                                                                                                                                                                                                                                                                |
| `outbox`             | `emit_event(type, payload)` in the writing transaction, named consumers with their own cursor, backoff and dead letters, `track_events(table)` and a relay to any `EventSink`. Other modules emit to it once it's installed. See [Outbox](/docs/blocks/outbox)                                                                                                                                                                                                               |
| `notifications`      | `notify(jsonb)` as a `security definer` sender, per-recipient read, dismissed and resolved state, subject subscriptions, channel preferences, email and push deliveries, and a private realtime topic. See [Notifications](/docs/blocks/notifications)                                                                                                                                                                                                                       |
| `inbox`              | Shared inboxes per organization: contacts with channel identities, conversations with assignment, teams, status, snoozing and bot handoff, messages and internal notes, read state, deliveries, a webhook event store, attachments in a private bucket and private realtime topics. See [Inbox](/docs/blocks/inbox)                                                                                                                                                          |
| `chat-sdk-state`     | The Chat SDK state tables: subscriptions, token-checked locks, a TTL cache, lists and per-thread queues, behind service-only functions, and `purge_chat_state`. See [Chat SDK state](/docs/chat-sdk/state)                                                                                                                                                                                                                                                                   |
| `webhooks-out`       | Customer webhook destinations with event subscriptions, Vault secrets with rotation, a delivery log with leases, retries, redelivery and auto-disable, and `publish_webhook_event` as a service-only fan-out. See [Outgoing webhooks](/docs/blocks/webhooks-out)                                                                                                                                                                                                             |
| `webhooks-in`        | Per-tenant trigger URLs with hashed tokens, optional Standard Webhooks or HMAC verification with secrets in Vault, a body limit, receive counters and a rate limit; deliveries go to the webhook inbox. See [Incoming webhooks](/docs/blocks/webhooks-in)                                                                                                                                                                                                                    |
| `streams`            | Durable streams: ordered chunks with idempotent appends, a cancel flag the writer reads on its next append, owner reads through RLS, a payload-free Realtime ping per stream and `purge_streams`. See [Durable streams](/docs/blocks/streams)                                                                                                                                                                                                                                |
| `credentials`        | `credential_get`, `credential_set` and `credential_delete` over Supabase Vault, for `service_role` only, behind `vaultCredentials()`. See [Credentials](/docs/extending/credentials)                                                                                                                                                                                                                                                                                         |
| `ai-chat`            | Chats, projects and a branching message tree in the canonical AI message format, runs with a compare-and-set stream claim and step progress, tool approvals and policies, pending inputs, cited sources, feedback, hashed share links, a model catalog per plan, moderation events, experimental harness sessions, the `ai_sandboxes` registry of chat and harness sandboxes with a service-only idle stop, and private Realtime topics. See [AI chat](/docs/blocks/ai-chat) |
| `ai-files`           | Files for AI chats in a private bucket whose policies only accept a reserved upload, provider file references with expiry, versioned documents with suggested edits, and a purge for stale uploads and the files of deleted chats. See [AI files](/docs/blocks/ai-files)                                                                                                                                                                                                     |
| `knowledge`          | Documents and chunks for retrieval, each chunk with an embedding and a `tsvector`, scoped to an organization, agent, project, chat or user; hybrid search with reciprocal rank fusion, chunks that keep their embedding when unchanged, and an embed job per document when `jobs` is installed. See [Knowledge](/docs/blocks/knowledge)                                                                                                                                      |
| `memory`             | Core memory files under `/memories` edited with the memory tool commands, archival facts with embeddings, one embedding per chat message for recall, namespaces per user, agent, chat or organization, and versioned documents for agent runtimes. See [Memory](/docs/blocks/memory)                                                                                                                                                                                         |
| `agents`             | Saved assistants with instructions, a model, tools, connectors, knowledge scopes and starters; private, organization or public visibility, installs, ratings and skill references. See [Agents](/docs/blocks/agents)                                                                                                                                                                                                                                                         |
| `connectors`         | MCP servers per organization, one OAuth grant per user behind a `credential_ref` that is revoked with the grant, MCP sessions per chat, and tool list fingerprints an admin approves. See [Connectors](/docs/blocks/connectors)                                                                                                                                                                                                                                              |
| `ai-tasks`           | Prompts scheduled on a cron in a time zone, a service-only tick that queues due runs to the `ai_task_run` queue, and a run log. See [AI tasks](/docs/blocks/ai-tasks)                                                                                                                                                                                                                                                                                                        |
| `push`               | `push_devices` per user with RLS, `register_push_device` and `unregister_push_device` for the user, and `push_tokens_for` and `prune_push_tokens` for `service_role`. See [Push notifications](/docs/blocks/push)                                                                                                                                                                                                                                                            |
| `ai-cache`           | Model responses cached under a key the app derives from the request, with a TTL capped by `maxTtl`, an entry size cap (`maxBytes`), hit counts and a purge of expired entries; only the service role reads and writes it. See [AI cache](/docs/blocks/ai-cache)                                                                                                                                                                                                              |
| `ai-providers`       | Per-tenant provider keys stored as `credential_ref`s, and the `ai_batches` registry of provider batch jobs with their stored results. See [AI providers](/docs/blocks/ai-providers)                                                                                                                                                                                                                                                                                          |
| `api-keys`           | `api_keys` with only the SHA-256 of each secret, `create_api_key`, `rotate_api_key`, `revoke_api_key`, `verify_api_key(public_id, secret_hash)` for the server and `has_scope(scope)` for policies. See [API keys](/docs/blocks/api-keys)                                                                                                                                                                                                                                    |
| `settings`           | Per-user and per-organization settings with defaults, `get_user_settings()`, `set_organization_setting(tenant, key, value)` and the reset functions. See [Settings](/docs/blocks/settings)                                                                                                                                                                                                                                                                                   |
| `usage`              | Usage counters per tenant, meter and period, `record_usage` with idempotency keys, quotas per tenant or plan, and `within_quota(tenant, meter)` and `consume_quota` for RLS and RPCs. See [Usage and quotas](/docs/blocks/usage)                                                                                                                                                                                                                                             |
| `billing`            | Stripe customers per tenant, `billing_status(tenant)`, `billing_seat_count(tenant)` and `link_billing_customer`. See [Billing](/docs/blocks/billing)                                                                                                                                                                                                                                                                                                                         |
| `flags`              | Flags with targeting rules, overrides and percentage rollouts, and `flag_enabled(key)` and `tenant_ids_with_flag(key)` for policies, bucketed the same way as the TypeScript provider. See [Feature flags](/docs/blocks/flags)                                                                                                                                                                                                                                               |
| `comments`           | Threaded comments on any record with mentions, `create_comment`, `list_comments`, and an activity feed built from outbox events. See [Comments and activity](/docs/blocks/comments)                                                                                                                                                                                                                                                                                          |
| `attachments`        | Attachment records linked to Storage objects, tenant-scoped Storage policies, and a scan status that gates downloads. See [Attachments](/docs/blocks/attachments)                                                                                                                                                                                                                                                                                                            |
| `data-lifecycle`     | Data exports per user or organization, `request_organization_deletion(tenant)` with a grace period, `cancel_organization_deletion` and `purge_organization`. See [Data lifecycle](/docs/blocks/data-lifecycle)                                                                                                                                                                                                                                                               |
| `sso`                | Verified organization domains with auto-join, SAML providers per organization, SSO enforcement in the access token hook and the SCIM user and group functions. See [SSO and SCIM](/docs/blocks/sso)                                                                                                                                                                                                                                                                          |
| `onboarding`         | `onboarding_progress` per user or organization, `complete_onboarding_step(checklist, step, tenant)` and a trigger that completes steps from outbox events. See [Onboarding](/docs/blocks/onboarding)                                                                                                                                                                                                                                                                         |
| `waitlist`           | `join_waitlist(email)`, approvals, hashed invite codes with use limits and an optional organization, and a trigger that redeems the code on sign-up. See [Waitlist and invite codes](/docs/blocks/waitlist)                                                                                                                                                                                                                                                                  |
| `announcements`      | Announcements with audiences, a time window and severity, `active_announcements(tenant)`, `dismiss_announcement(id)` and a broadcast on a Realtime topic. See [Announcements](/docs/blocks/announcements)                                                                                                                                                                                                                                                                    |
| `workflows`          | `workflow_runs` for any engine, read through RLS, with a Realtime ping per status change and outbox events when a run ends; cron schedules, counting semaphores, admission control for starts and `purge_workflow_runs`. See [Workflows](/docs/blocks/workflows)                                                                                                                                                                                                             |
| `workflow-sdk-world` | The Workflow SDK World's tables in a `workflow` schema only the service role reads, a trigger that copies each run into `workflow_runs`, and `dispatch_workflow_deliveries()` for pg\_net delivery on a pg\_cron schedule. See [Workflow SDK](/docs/blocks/workflow-sdk)                                                                                                                                                                                                     |
| `workflow-builder`   | Graph workflows per tenant: definitions, numbered versions checked and published in SQL, webhook, schedule and event triggers, credentials by `credential_ref`, the step library, node-level run status with a Realtime ping, and alerts on failed or slow runs as outbox events. See [Workflow builder](/docs/blocks/workflow-builder)                                                                                                                                      |
| `ensure-rls`         | An event trigger that enables row level security on every new table outside the Supabase-managed schemas. Install it as `postgres`, which supautils lets create event triggers. See [below](#rls-on-every-new-table)                                                                                                                                                                                                                                                         |

`invitations` and `organizations` need `tenant` and `access`, so adding one
pulls those in as well. They read the active tenant from the source in
`sql.modules.access.activeTenant`: by default the tenant the server resolved for the
request, then the claim named by `claims.tenant` (`tenant_id` by default).
The roles come from `sql.modules.access.roles`. Change modules through `sql.modules` (see
[below](#existing-tables-managed-adopt-and-custom)) rather than by editing
the files: `sql sync` rewrites them, and doctor reports a hand edit.

When the config's [authorization provider](/docs/extending/authorization-providers)
has a token hook that owns the `memberships` claim (`tokenHook.ownedClaims`),
`sql add tenant` stops, because two hooks would write that claim. Pass
`--force` to write it anyway,
for example to keep the helpers while you migrate. Modules that need `tenant`,
such as `invitations`, are written without `--force`, with `tenant` as a
dependency and a note: its memberships table backs `has_organization_role()`, and no
hook should call `membership_claims()`. `entitlements` needs `tenant` only
with `entitlements.memberships: "tenant"`: with a provider, it reads the
provider's `memberIds` functions instead. See
[entitlements with an authorization provider](/docs/blocks/entitlements#with-an-authorization-provider).

```sql
select better_supabase.track_updated_at('public.customers');
select better_supabase.track_actor('public.customers');
select better_supabase.audit('public.customers', ignore => '{updated_at}');

create policy "admins update" on public.customers for update to authenticated
  using (organization_id in (select better_supabase.member_organization_ids('{owner,admin}')))
  with check (organization_id in (select better_supabase.member_organization_ids('{owner,admin}')));
```

`member_organization_ids()` returns the user's organizations as a set, so Postgres runs
it once per statement and compares each row against the result. Calling
`has_organization_role(organization_id)` in a policy runs it once per row instead; keep
that one for functions and single checks. `with check` stops an update from
moving a row into an organization the user can't manage.

Admins can invite members, viewers and other admins. Only an owner, or the
service role, can invite an owner, and the invitee has to confirm their email
address before `accept_invitation` adds them.

Module tables use `gen_random_uuid()` keys. Postgres 18's `uuidv7()` gives
time-ordered keys that keep indexes compact, and the modules move to it once
hosted Supabase runs Postgres 18
([supabase/postgres#2051](https://github.com/supabase/postgres/pull/2051)).

Errors raised by the modules carry a stable code in the `hint` field, for example
`SLUG_RESERVED`, `SLUG_INVALID`, `INVITATION_INVALID`, `INVITATION_EXPIRED`,
`INVITATION_ROLE_FORBIDDEN` or `INVITATION_EMAIL_UNCONFIRMED`. They come back as
typed `DbError`s through the repository.

## Existing tables: managed, adopt and custom [#existing-tables-managed-adopt-and-custom]

Each module has a contract: the functions policies, other modules and the
TypeScript side call. `sql.modules.<module>.mode` decides what stands behind it.

| Mode                | The module writes                                                                           |
| ------------------- | ------------------------------------------------------------------------------------------- |
| `managed` (default) | its tables, policies and functions                                                          |
| `adopt`             | only functions and views, over tables you already have; never `create table`                |
| `custom`            | nothing: you write the contract functions, and [doctor](/docs/cli/doctor#bs307) checks them |

`better-supabase sql list` marks custom modules, and `sql print <module>` shows
the signatures a custom module must provide. Not every module supports every
mode; `tenant` and `access` support all three.

The other keys rename what the module reads and writes, so an app keeps its
own names:

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      tenant: {
        mode: "adopt",
        tables: { memberships: "public.organization_users" },
        columns: {
          memberships: { tenant: "organization_id", lastUsedAt: null },
        },
        options: { claimFormat: "map" },
      },
      invitations: {
        permissions: { invite: "organization.members.invite" },
      },
    },
  },
});
```

| Key           | What it sets                                                                                                                                          |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode`        | `managed`, `adopt` or `custom`                                                                                                                        |
| `schema`      | the schema of the module's functions and managed tables (`better_supabase`); `jobs` takes none, and `access` keeps its functions in `better_supabase` |
| `tables`      | logical table to `table` or `schema.table`; `null` for an optional table you don't have                                                               |
| `columns`     | logical table to logical column to column name; `null` for an optional column                                                                         |
| `idType`      | the tenant id type: `uuid` (default), `text`, `bigint` or `integer`                                                                                   |
| `permissions` | block action to permission key, checked through the [access contract](/docs/blocks/access)                                                            |
| `options`     | module options, listed on each module's page                                                                                                          |
| `hooks`       | where the module looks for the app's [SQL hooks](/docs/extending/events#sql-hooks)                                                                    |
| `events`      | `false` stops the module writing its events to the outbox                                                                                             |
| `audit`       | `false` stops the module writing its actions to the [audit log](/docs/blocks/audit#module-actions)                                                    |
| `api`         | a schema for the Data API that gets entry points for the module's functions (see below)                                                               |

`organizations` and `profiles` also take `options.extraColumns`: your own
columns, keyed by name with their SQL type, which the module adds to its
managed table, copies on writes and returns from its reads. A name the module
already uses is refused. [Extending blocks](/docs/extending/blocks#your-own-fields)
shows how to type them in TypeScript.

### Calling a module over the Data API [#calling-a-module-over-the-data-api]

The module schema holds tables and helpers that policies call, so it never
belongs in `[api] schemas` (doctor reports it as
[BS312](/docs/cli/doctor#bs312)). To call a module from a browser or an
isolate without a Postgres connection, give it an API schema:

```ts title="better-supabase.config.ts"
sql: {
  modules: {
    settings: { api: "api" },
    notifications: {
      api: { schema: "api", functions: ["list_notifications", "notification_counts"] },
    },
  },
},
```

`sql add` then writes a `security invoker` function in `api` for each module
function that `anon`, `authenticated` or `service_role` may execute, or only
those listed in `functions`, with the same name and arguments, granted to the
same of those roles. The wrapper runs as the caller, so the module's own
permission checks and RLS still apply, and a service function such as
`flag_definitions` stays callable by `service_role` only. Each wrapper
revokes execute from `public` and from every API role it isn't meant for,
so `alter default privileges in schema api grant execute on functions to
anon` (a common setup for an exposed schema) doesn't let `anon` call a
member or service function. Helpers that a
module grants to `authenticated` only so its own policies and triggers can
call them get no entry point, and `functions` refuses them:
`guard_membership_role` (organizations), `invitation_tenant_ids` and
`platform_invitations_readable` (invitations), `comment_subject_readable`
(comments), the `attachment_*` and `object_clean` policy helpers
(attachments), `incoming_webhook_tenant_ids` and
`incoming_webhook_subject_readable` (webhooks-in), `data_export_object_allowed`
(data-lifecycle), `mfa_satisfied` (mfa) and `session_active` (sessions). A
wrapper an earlier `sql sync` wrote for one of them is dropped. Add the
schema to `[api] schemas` in `config.toml` and pass it to the transport:

```ts
const client = settings.connect({
  transport: rpcTransport(supabase, { schema: "api" }),
});
```

An app server that reaches the database only through PostgREST uses the same
wrappers with a service-role client, for example for feature flags and
announcements:

```ts
const transport = rpcTransport(serviceClient, { schema: "api" });
const flags = createFlagsProvider({ transport });
const announcements = createAnnouncements({ transport });
const outbox = createOutbox(transport, { source: "https://app.example.com" });
```

An unknown table, column or module, or a mode a module doesn't support, stops
`sql add` and `sql sync` with the valid names. Each file's header records the
module version and mode (`-- @bs-module tenant@2 adopt`), and the data migration
records them in `better_supabase.modules`. An option a module doesn't read
stops `sql add` as well, so a misspelled option can't fall back to its default
without notice. Only `sql.modules.access` takes the access keys (`model`, `roles`,
`functions`, `activeTenant` and the rest).

## Audit log [#audit-log]

`audit(table)` records every insert, update and delete of a table. The other
parameters name and filter what it writes:

```sql
select better_supabase.audit(
  'public.customers',
  ignore => '{updated_at}',
  redact => '{tax_id}',
  category => 'billing',
  event_prefix => 'customer',
  target_type => 'customer'
);
```

`audit()` keeps these settings on the table's `bs_audit` trigger, as the
JSON argument of `audit_row_change`, and writes no rows. So a call in a
schema file leaves nothing a schema diff refuses (pg-delta rejects a table
that has rows after loading the schema), and the generated migration carries
the trigger with its settings; no data migration is needed. You can also
write the trigger yourself, which pg-delta orders after the table:

```sql title="supabase/schemas/020_customers.sql"
create trigger bs_audit after insert or update or delete on public.customers
  for each row execute function better_supabase.audit_row_change(
    '{"ignore": ["updated_at"], "redact": ["tax_id"], "event_prefix": "customer"}'
  );
```

The argument takes `ignore`, `redact`, `key_columns` (the primary key by
default), `category`, `event_prefix`, `target_type`, `tenant_column` and
`label_column`. `audit_settings(table)` returns a table's settings. Tables
registered before this release keep their rows in `audited_tables`, and the
trigger reads them while it has no argument; calling `audit()` again moves
them onto the trigger.

`redact` columns are stored as `[redacted]` in the old and new records. The
event type is `<event_prefix>.created`, `.updated` or `.deleted` (the table
name without a prefix), and `tenant_column` overrides the tenant column for
this table. Events that are not row changes, like a sign-in or an export, go
through `audit_event`. It returns the entry id, and a repeated
`idempotency_key` returns the first entry instead of writing a second:

```sql
select better_supabase.audit_event(
  event_type => 'invoice.exported',
  category => 'billing',
  tenant => '6d1f...',
  metadata => '{"format":"csv"}',
  idempotency_key => 'export-42'
);
```

A job, a webhook handler or an admin tool that records an event as the
service role can say who acted and from where: `actor_id`, `actor_kind`
(such as `job` or `api-key`), `actor_label`, `request_id` (the request it
handled), `scope` (an adopted log's own value, or `tenant` and `platform`),
and, with the restricted table, `ip`, `user_agent` and `session_id`. The function takes these only from the
service role and direct admin connections; for any other caller it uses
`auth.uid()`, the request's JWT and headers, so a user can't record an event
in someone else's name. For the service role these three come only from
the arguments, never from the request, so a server that calls
`audit_event` over the Data API doesn't store its own address and user
agent as the user's. An event whose `restricted`, `ip`, `user_agent` and
`session_id` are all empty writes no restricted row. Without the restricted
table, passing `ip`, `user_agent` or `session_id` fails with `22023`, as
`restricted` does:

```sql
select better_supabase.audit_event(
  event_type => 'export.finished',
  actor_kind => 'job',
  actor_label => 'Nightly export',
  ip => '198.51.100.7',
  user_agent => 'export-worker/1.0',
  restricted => '{}'
);
```

An app's own `security definer` function that acts for a client, such as a
role editor saving permissions, runs as its owner but still carries the
client's JWT, so `audit_event` would replace its actor context with the
client's. Such a function calls `better_supabase.audit_event_trusted`
instead: it takes the same arguments and honours `actor_id`, `actor_kind`,
`actor_label`, `scope` and the request details from its caller, with
`actor_id` defaulting to `auth.uid()`. Only its owner, the service role and
the roles in `trustedRoles` may execute it, so a client can't call it to
forge an actor, and the module writes no Data API entry point for it:

```sql
create function public.save_role_permissions(role_id uuid, keys text[])
returns void language plpgsql security definer set search_path = '' as $$
begin
  -- write the permissions, then:
  perform better_supabase.audit_event_trusted(
    event_type => 'role.permissions_saved',
    record_id => role_id::text,
    actor_kind => case when better_supabase.is_platform('roles.manage') then 'support' else 'user' end,
    scope => 'platform'
  );
end;
$$;
```

The module options (`sql.modules.audit.options`):

| Option                | Default                  | What it does                                                                                                                                                                                                                                                                                        |
| --------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tenantColumn`        | `plugins.tenant`         | the tenant column of audited tables                                                                                                                                                                                                                                                                 |
| `appendOnly`          | `true` when managed      | triggers reject updates, deletes and truncate; only `purge_audit_log`, running as its owner, deletes                                                                                                                                                                                                |
| `readPolicy`          | `false`                  | members read their tenants' entries with the `view` permission, platform staff all of them with `viewAll` (needs [access](/docs/blocks/access))                                                                                                                                                     |
| `impersonators`       | `show`                   | `hide` keeps the impersonation columns from `authenticated`                                                                                                                                                                                                                                         |
| `restricted`          | `false`                  | old and new records, IP address, user agent and metadata go to a separate `audit_events_restricted` table that only `service_role` reads; without it, `audit_event(restricted => ...)` fails with `22023`                                                                                           |
| `eventRoles`          | `['service_role']`       | the roles that can call `audit_event`                                                                                                                                                                                                                                                               |
| `trustedRoles`        | `[]`                     | roles besides the owner and `service_role` that can call `audit_event_trusted`, such as the owner role of your definer functions when it isn't the module's owner                                                                                                                                   |
| `eventCategory`       | `system`                 | the category of an `audit_event` call without one                                                                                                                                                                                                                                                   |
| `eventSource`         | `app`                    | the source of an `audit_event` call without one                                                                                                                                                                                                                                                     |
| `exempt`              | `[]`                     | `schema.table` globs (`public.*_archive`) that [doctor BS315](/docs/cli/doctor#bs315) doesn't expect an audit trigger on                                                                                                                                                                            |
| `tenantLabel`         | the organizations module | `schema.table.column` that `tenant_label` copies, matched on `tenantLabelKey` (`id` by default), for apps without the `organizations` module                                                                                                                                                        |
| `values`              | none                     | adopt mode only: the adopted log's values for `scope`, `actorKind`, `outcome`, `source` and `category`, see below                                                                                                                                                                                   |
| `metadataColumns`     | none                     | adopt mode only: the adopted log's own columns that `audit_event` fills from a key of its `metadata`, such as `{ ticket_id: "ticketId" }`; the value is converted to the column's type, a missing key leaves the column to its default, and `list_audit_events` returns the columns under `columns` |
| `keepMappedMetadata`  | `false`                  | keeps the keys `metadataColumns` maps in the stored `metadata` too; by default they are removed, since the columns hold them                                                                                                                                                                        |
| `requestIdHeader`     | `x-request-id`           | the Data API header `request_id` is read from when the `better_supabase.request_id` setting is empty                                                                                                                                                                                                |
| `correlationIdHeader` | `x-correlation-id`       | the Data API header `correlation_id` is read from when the `better_supabase.correlation_id` setting is empty                                                                                                                                                                                        |

`sql add` and `sql sync` read the `audit(...)` calls and `bs_audit` triggers in
your schema files and migrations, and write a pgTAP file per audited table to `sql.testsDir`
(`supabase/tests/900_better_supabase_audit_public_customers.test.sql`). It
checks that the table has the `bs_audit` trigger and is registered with the
same `ignore` and `redact` lists, then runs an insert, an update and a delete
on a copy of the table and checks the entries: one per change, without the
ignored columns, with the redacted values masked. `supabase test db` runs it
with your other tests, and `sql sync --check` fails when a registration changed
and the file is stale. A later `unaudit(...)` call or `drop table` removes
the table's file on the next `sql sync`. Calls with computed arguments, such
as `audit(format(...))`, are skipped.

Dropping an audited table also drops its registration: the module installs
a `sql_drop` event trigger (`bs_audit_forget_dropped`) that deletes the
table's row from `better_supabase.audited_tables`, so install the module as
`postgres`, which supautils lets create event triggers. `unaudit` takes the
table name as text, so a migration can still call
`select better_supabase.unaudit('public.old_notes')` after the table is
gone; it then clears the registrations of tables that no longer exist.
Tables dropped before the event trigger existed keep their rows; the
module's data file deletes them, so the next `sql data` migration cleans
them up, and [doctor BS322](/docs/cli/doctor#bs322) reports any that remain
on a database.

An app with its own audit table adopts it: map `sql.modules.audit.tables.log` and
`columns.log` to your names, and `null` the columns you don't have (the row
snapshots or `supportSession`, for example). The module then writes into your table and never
creates it.

When the adopted log uses its own words for the same values, map them with
`options.values`: the module's value to yours, per column. Writes store your
values, and `list_audit_events` (and so `createAuditLog`) reads them back as
the module's, so exports and OCSF mapping keep working. Doctor reports the
option as migration-only ([BS314](/docs/cli/doctor#bs314)).

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      audit: {
        mode: "adopt",
        tables: { log: "public.activity_log" },
        columns: {
          log: {
            tenant: "workspace_id",
            actorKind: "actor_type",
            tenantLabel: "workspace_name",
          },
        },
        options: {
          values: {
            scope: { tenant: "workspace", platform: "global" },
            actorKind: { user: "member", service: "api" },
            outcome: { success: "ok", failure: "error" },
            source: { database: "db" },
            category: { data: "record", system: "platform" },
          },
          tenantLabel: "public.workspaces.title",
          tenantLabelKey: "workspace_id",
        },
      },
    },
  },
});
```

`source` covers row changes (`database`) and `audit_event` calls alike, and
`category` maps the defaults (`data` for row changes, `eventCategory` for
events) and any category a registration or an `audit_event` call names.
Values without a mapping are stored as they are. Two module values can't map
to the same stored value, because reads couldn't tell them apart.

### Context columns [#context-columns]

Every entry also records what an audit page shows, as it was at the time:

| Column           | What it holds                                                                                       |
| ---------------- | --------------------------------------------------------------------------------------------------- |
| `actor_kind`     | `user`, `service`, `support`, `impersonation`, `oauth-client` or `system`, from the claims          |
| `actor_label`    | the actor's `full_name` metadata or email                                                           |
| `tenant_label`   | the organization's name, with the `organizations` module or `options.tenantLabel`                   |
| `target_label`   | the value of the table's `label_column` (`audit(..., label_column => 'title')`), or `audit_event`'s |
| `summary`        | `audit_event(summary => ...)`                                                                       |
| `request_id`     | the request id, see below                                                                           |
| `correlation_id` | `audit_event(correlation_id => ...)`, or the request's correlation id, see below                    |
| `scope`          | `tenant`, or `platform` for entries without a tenant                                                |

With `restricted`, the restricted table also keeps the `session_id` claim and
`changed_values`: `{ column: { old, new } }` for each changed column (every
column on insert and delete), redacted like the records and cut at 1000
characters, so one change can be revealed without the whole rows. A managed
log has all of these columns; an adopted one writes the ones `columns.log`
and `columns.restricted` map.

### Request and correlation ids [#request-and-correlation-ids]

Row changes and `audit_event` calls record the request id and the correlation
id of the request that made them, so an audit page can show one user action
as the event plus the rows it changed. Each id comes from the first of:

1. the `audit_event` argument (`request_id` counts only for the service role
   and direct admin connections, like the other actor details),
2. the transaction-local setting `better_supabase.request_id` or
   `better_supabase.correlation_id`, which a server sets over direct Postgres
   with `set_config(..., true)`,
3. the Data API request header, `x-request-id` or `x-correlation-id` unless
   the `requestIdHeader` and `correlationIdHeader` options name others.

`better_supabase.request_id_or_null(value)` keeps an id of 1 to 128
characters from `A-Z`, `a-z`, `0-9` and `. _ : ; , @ / + = -` and turns
anything else into null, so an invalid value falls through to the next
source. The ids are metadata a client can set; never use them for access
decisions. [`createServer`](/docs/auth/server#request-and-correlation-ids)
sends both on every request without configuration.

A managed log indexes `correlation_id`. Group an action's entries with the
`for_correlation_ids` filter, or in SQL:

```sql
select occurred_at, op, event_type, table_name, record_id, changed
from better_supabase.audit_events
where correlation_id = 'checkout/42'
order by occurred_at, id;
```

From TypeScript, `audit.list({ correlationId: ctx.correlationId })` returns
the same entries.

### Registering many tables [#registering-many-tables]

A project with a hundred tenant tables doesn't write a hundred `audit(...)`
lines by hand. `audit_schema_calls` prints them for every table in a schema
with the tenant column, minus exempt name patterns:

```sql
select better_supabase.audit_schema_calls('public', 'organization_id', '{audit_%,%_archive}');
```

Paste the output into a schema file, and override single tables with their
own `audit(...)` call after it. Static calls keep their place in a pg-delta
diff; a loop over the catalog in a schema file runs before the tables exist
(doctor [BS318](/docs/cli/doctor#bs318)). For a migration or a one-off script,
`audit_schema(schema, tenant_column, exempt)` registers the tables that aren't
registered yet and returns how many. Doctor [BS315](/docs/cli/doctor#bs315)
flags any table left out.

The [audit block](/docs/blocks/audit) reads the log from TypeScript: a list
query, NDJSON and OCSF exports, and retention per tenant. In SQL,
`list_audit_events` lists the entries the caller can read, and
`reveal_audit_entry(entry)` returns an entry's restricted details and records
the read as `audit.revealed`. `reveal_audit_entries(entries)` does the same
for a list of entry ids, such as one page of an export: it returns the
details of the entries the caller may reveal, skips the rest, and records
one `audit.revealed` entry per tenant with the ids in `metadata.entries`.

## Retention [#retention]

The audit log, the webhook inbox and delivery log, the rate-limit counters
and the job archives only grow. Each module has a purge function that deletes
rows older than an interval, at most `batch` rows per call (10,000 by
default), and returns how many it deleted. Only `service_role` can call them.

| Function                                                                         | Deletes                                                                                                            |
| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `purge_audit_log(older_than => '1 year')`                                        | `audit_events` entries older than `older_than`, or than the tenant's own interval                                  |
| `purge_webhooks(older_than => '30 days')`                                        | processed messages; `include_dead => true` also deletes dead ones                                                  |
| `purge_job_archive(queue, older_than => '7 days', dead_older_than => '30 days')` | completed jobs, and dead jobs after their own retention (`pgmq.a_<queue>`, or `job_messages` on the table backend) |
| `purge_idempotency_keys()`                                                       | expired idempotency keys                                                                                           |
| `purge_webhook_deliveries(older_than => '30 days')`                              | succeeded and canceled outgoing deliveries; `include_dead => true` also deletes dead ones                          |
| `purge_rate_limits()`                                                            | counters whose window has ended or whose rule was removed                                                          |

Schedule them with [pg\_cron](https://supabase.com/docs/guides/cron) at a quiet
hour. Enable the extension once (`create extension pg_cron with schema
pg_catalog`), then:

```sql
select cron.schedule('purge-audit-log', '15 3 * * *', $$select better_supabase.purge_audit_log()$$);
select cron.schedule('purge-webhooks', '30 3 * * *', $$select better_supabase.purge_webhooks()$$);
select cron.schedule('purge-emails-archive', '45 3 * * *', $$select better_supabase.purge_job_archive('emails')$$);
select cron.schedule('purge-idempotency-keys', '0 4 * * *', $$select better_supabase.purge_idempotency_keys()$$);
select cron.schedule('purge-webhook-deliveries', '45 3 * * *', $$select better_supabase.purge_webhook_deliveries()$$);
select cron.schedule('purge-rate-limits', '*/15 * * * *', $$select better_supabase.purge_rate_limits()$$);
```

Tenants can keep entries for different periods, a plan's retention for
example. Write an `audit_retention(tenant)` function that returns an interval
(or null for the default) in the schema `sql.modules.audit.hooks` points at, and
`purge_audit_log` uses it for every entry. Like every SQL hook, it is
optional: the module calls hooks through dynamic SQL, so `supabase db lint`
passes without them. To decide in TypeScript instead,
call `purgeAuditLog` from [`better-supabase/blocks/audit`](/docs/blocks/audit) in a scheduled job:

```ts
import { purgeAuditLog } from "better-supabase/blocks/audit";

await purgeAuditLog(postgres.admin, {
  olderThan: "1 year",
  retention: async (tenant) =>
    tenant ? await plans.auditDays(tenant) : undefined,
});
```

When a table holds more old rows than one batch, the next run picks up the
rest; run the job more often, or raise `batch`, until it keeps up. Keep the
webhook retention above your senders' retry window: a retried message whose id
was purged is stored and processed again.

## Support sessions [#support-sessions]

`support-sessions` records who viewed the app as whom, for
[support mode](/docs/auth/impersonation). It needs `access` and `audit`.
`start_support_session(target, reason, ttl, read_only, metadata, tenant)`
checks `is_platform('support.start')` for the caller, ends the caller's
previous session, writes `support.started` to the audit log and returns the
session as jsonb. An admin has one active session (a unique index). It refuses
a target with platform permissions and a writable session unless the options
below allow them. `end_support_session(session_id)` ends it for its admin
(`ended_by` is `admin`), a caller with `support.revoke` (`revoked`) or the
service role, which alone passes `ended_by`. `active_support_session` called
as the admin checks `support.start` again. Errors carry `SUPPORT_FORBIDDEN`,
`SUPPORT_SELF`, `SUPPORT_TARGET_MISSING`, `SUPPORT_TARGET_PLATFORM`,
`SUPPORT_WRITES_DISABLED`, `SUPPORT_TTL` or `SUPPORT_REASON_REQUIRED` in the
`hint`.

| Option                 | Default   | What it does                                                             |
| ---------------------- | --------- | ------------------------------------------------------------------------ |
| `maxTtl`               | `4 hours` | the longest session `start_support_session` accepts                      |
| `requireReason`        | `true`    | refuses a start without a reason                                         |
| `allowWrites`          | `false`   | accepts `read_only => false`                                             |
| `allowPlatformTargets` | `false`   | lets a session target platform staff                                     |
| `auditCategory`        | `support` | the audit log category of `support.started` and `support.ended`          |
| `claimsHook`           | none      | `schema.function` of your access token hook, for `support_target_claims` |

The permission keys are `support.start`, `support.read` and `support.revoke`;
rename them with `blocks["support-sessions"].permissions`. The hooks
`before_support_start(admin, target, reason, metadata)`,
`after_support_start(session_id)` and `after_support_end(session_id)` run in
the schema `hooks.schema` names. An existing sessions table can be adopted:
the `tenant`, `readOnly`, `endedBy` and `metadata` columns are optional.

```ts title="better-supabase.config.ts"
sql: {
  modules: {
    "support-sessions": {
      mode: "adopt",
      tables: { sessions: "public.impersonation_sessions" },
      columns: { sessions: { admin: "impersonator_id", metadata: null } },
      permissions: { start: "system.users.impersonate" },
      options: { claimsHook: "public.custom_access_token_hook" },
    },
  },
},
```

## Rate limiting writes [#rate-limiting-writes]

Auth rate-limits sign-ins, but the Data API has no limit of its own: a signed-in
user can call `insert` or an RPC as often as they like. The `rate-limit` module
counts writes in PostgREST's
[pre-request hook](https://docs.postgrest.org/en/v12/references/transactions.html#pre-request),
before the query runs:

```bash
better-supabase sql add rate-limit
```

```sql
-- 5 invites per user per minute, 300 writes per user per minute overall.
select better_supabase.set_rate_limit('/rpc/send_invite', 5, interval '1 minute');
select better_supabase.set_rate_limit('*', 300, interval '1 minute');

-- Per tenant instead of per user: count by the tenant_id claim.
select better_supabase.set_rate_limit('/customers', 1000, interval '1 hour', key_claim => 'tenant_id');

-- Remove a rule.
select better_supabase.set_rate_limit('/customers', null);
```

* A scope is `*`, a table path (`/customers`) or an RPC path
  (`/rpc/send_invite`). Every matching rule counts, so `*` and a path rule
  both apply.
* Only `POST`, `PATCH`, `PUT` and `DELETE` count. GET and HEAD run read-only
  and may be served by a [read replica](/docs/guides/read-replicas). A `POST`
  to `/rpc` for a `stable` or `immutable` function also runs in a read-only
  transaction, so `check_request()` skips it instead of failing the call.
* Callers without the claim (anonymous) are counted by the right-most
  `x-forwarded-for` hop, the one the API gateway appends; clients can forge
  the hops before it. The service role is never limited.
* Counters live in an unlogged table: fast, and reset after a crash.
* A write over the limit fails with HTTP 429 and a `Retry-After` header. The
  repository returns it as a `rate_limited` [`DbError`](/docs/concepts/results)
  with `retryAfter` in seconds, and `problemResponse()` passes the header on.

The module's data file sets `pgrst.db_pre_request` on the `authenticator`
role, and only if no other pre-request function is set. Neither diff engine
captures role settings, so the setting ships in the migration `sql data`
writes. Doctor warns (BS313) when a live database has no pre-request hook, or
one that doesn't call `check_request()`. If you already have a pre-request
function, keep it and call the check from it. Put it in a schema the Data API doesn't expose, so clients
can't call it through `/rpc`. Set `options.preRequest: false` to leave the
setting to your own function: the data file then removes a setting that
still points at `check_request()` instead of adding one.

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: { modules: { "rate-limit": { options: { preRequest: false } } } },
});
```

```sql
create schema if not exists private;
create or replace function private.pre_request() returns void language plpgsql as $$
begin
  perform better_supabase.check_request();
  -- your checks
end $$;
```

The hook runs only for the Data API. Queries through
[`better-supabase/postgres`](/docs/auth/postgres) and `ctx.sql` aren't limited.

### Limits in route handlers [#limits-in-route-handlers]

Public endpoints often limit by something other than a claim: a token in the
URL, an organization, an AI chat thread. `hit_rate_limit(scope, key)` counts
one hit of `key` with the same rules and counters, and `createRateLimit` from
`better-supabase/blocks/jobs` calls it from a route handler. `rateLimited()`
answers a refused hit with a `problem+json` 429 and `Retry-After`:

```ts
import { createRateLimit, rateLimited } from "better-supabase/blocks/jobs";

const limits = createRateLimit(postgres.admin);

export async function POST(
  request: Request,
  { params }: { params: { token: string } },
) {
  const hit = await limits.check("public-webhook", params.token).orThrow();
  if (!hit.allowed)
    return rateLimited(hit, { instance: new URL(request.url).pathname });
  // handle the request
}
```

Apps that reach the database over the Data API instead of a direct
connection pass a service-role `BlockTransport`, like the other blocks:
`createRateLimit(rpcTransport(adminClient))`. It calls
`check_rate_limit(scope, key, max_requests, period)`, which returns the same
decision as JSON; with `sql.modules.rate-limit.api` set, pass the API schema
as `rpcTransport(adminClient, { schema: "api" })`.

The limit is the scope's rule from `set_rate_limit`, or one given per call
(`check("chat", threadId, { max: 20, period: "1 minute" })`). A scope with
neither fails with `RATE_LIMIT_UNKNOWN`. Counters are shared by every
instance, unlike an in-memory map on serverless functions.
`purge_rate_limits()` removes counters without a rule after a day, so keep a
per-call `period` under a day or define the rule.

## Enforcing jsonb shapes [#enforcing-jsonb-shapes]

Give a `json` entry a `schema` and `sql add jsonb-schemas` adds a
[pg\_jsonschema](https://supabase.com/docs/guides/database/extensions/pg_jsonschema)
check constraint, so the database rejects rows the generated types would
reject. `schema` takes a JSON Schema object, or any Standard JSON Schema value
(zod 4, valibot, arktype):

```ts title="better-supabase.config.ts"
import { toStandardJsonSchema } from "@valibot/to-json-schema";
import * as v from "valibot";

export const CustomerMetadata = toStandardJsonSchema(
  v.object({
    source: v.string(),
    tier: v.optional(v.picklist(["free", "pro"])),
  }),
);

export default defineConfig({
  json: {
    "customers.metadata": {
      import: "./lib/metadata.ts#CustomerMetadata",
      schema: CustomerMetadata,
    },
  },
  sql: { modules: ["jsonb-schemas"] },
});
```

Each constraint is added `not valid` and then validated in its own
statement, so existing rows that don't match make the migration fail. Fix
those rows first. On a large table, move the `validate constraint` statement
to a later migration: adding a `not valid` check blocks writes only briefly,
and validating takes a lock that lets writes continue. Re-run `sql add` after
you change a schema.

Next to each constraint, a `before insert or update` trigger of the same name
calls pg\_jsonschema's `jsonschema_validation_errors`. A row that fails raises
SQLSTATE `23514` with the hint `JSON_SCHEMA_INVALID` and the errors as a JSON
array in `DETAIL`, so the repository returns a `validation` error with one
issue per schema error, at the column's database name:

```ts
// metadata came from a request body as { source: "web", tier: "gold" }
const result = await db.customers.update(id, { metadata });
if (!result.ok && result.error.kind === "validation") {
  result.error.issues;
  // [{ message: '"gold" is not one of ["free","pro"]', path: ["metadata"] }]
}
```

The check constraint stays for rows written while triggers are off, such as a
restore with `session_replication_role = replica`; its failures also map to
`validation`, with one issue for the whole value.

## RLS on every new table [#rls-on-every-new-table]

`sql add ensure-rls` installs an event trigger that enables row level
security on every table created with `create table`, `create table as` or
`select into`, so a table never reaches the Data API without RLS. Tables in
the Supabase-managed schemas (`auth`, `storage`, `realtime`, `extensions`
and the others) and in `pg_*` schemas are skipped.

```sql
create table public.notes (id bigint primary key, body text);
-- relrowsecurity is now true; add policies before granting access.
```

A table with RLS and no policies denies every API role, so add its policies
in the same migration. Apply the migration as `postgres`: event triggers
need a superuser, and on Supabase the supautils extension lets `postgres`
create them. Tables that already exist keep their setting.

## Keeping files current [#keeping-files-current]

Every file starts with a header that marks it as managed. Its number comes
from the module's position in the registry, so its path stays the same when
you add other modules. List the modules you use in the config:

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: ["audit", "jobs", "invitations"],
    testsDir: "supabase/tests",
  },
});
```

`better-supabase sql sync` rewrites those files after an upgrade, and
`sql sync --check` fails in CI when a file is out of date. Use
`sql print <module>` to copy a module into a hand-written migration instead.
`sql add`, `sql sync` and `sql data` take `--dry-run` to show what they would write, and
`--tests-dir <dir>` overrides `sql.testsDir` for the pgTAP module.

| Option         | Default               |
| -------------- | --------------------- |
| `sql.dir`      | `supabase/schemas`    |
| `sql.prefix`   | `900_better_supabase` |
| `sql.testsDir` | `supabase/tests`      |
| `sql.modules`  | `[]`                  |

## Upgrading modules [#upgrading-modules]

When a release changes a module in a way the schema diff can't follow (a
renamed column, a backfill that must run before a new constraint), the module's
version goes up and the release ships a forward step. Run the upgrade after you
update the package:

```bash
better-supabase sql upgrade
```

It reads the version from each file's `@bs-module` line (a file without one,
written before the line existed, counts as version 1). It then writes the
forward steps into `<timestamp>_better_supabase_block_upgrade.sql` in the
`migrations` folder next to your `config.toml` and rewrites the module files. Create the schema migration afterwards, so the
steps run first. `sql upgrade --check` exits with 1 when a module is behind or
a file is stale, and `--dry-run` lists the steps without writing. The pgTAP
files a module writes for your tables (the audit tests, say) carry no module
version; `sql sync --check` keeps them current, and `sql upgrade` leaves them
out.

A renamed function, table or claim keeps working for at least one minor
version. The module file keeps a wrapper under the old name (a view for a
table), marked `-- Deprecated since`, and doctor reports code that still uses
it (BS309). A renamed column has no wrapper: `sql upgrade` renames it, and
doctor reports the old name in your SQL before you upgrade. The
[stability page](/docs/extending/stability#sql-module-objects-and-claims) lists
what each module guarantees.

## Existing triggers [#existing-triggers]

`track_updated_at()` and `audit()` warn when the table already has a trigger
that does the same work (a `moddatetime` or `touch_updated_at` trigger, or
another audit trigger), because both would run on every write. Pass
`replace_trigger => true` to drop the existing trigger in the same call:

```sql
select better_supabase.track_updated_at('public.customers', replace_trigger => true);
select better_supabase.audit('public.customers', replace_trigger => true);
```

Doctor reports a table that still has both (BS310).

## Rendering modules in code [#rendering-modules-in-code]

`better-supabase/sql` exports what the `sql` command uses, for build scripts
and tests that write the files themselves. `moduleLayout(config)` returns the
paths, `renderModules` returns the files with their managed headers (dependencies
included), and `resolveModules` lists the modules a set of names pulls in.

```ts title="scripts/write-block.ts"
import { resolveConfig } from "better-supabase/config";
import {
  moduleLayout,
  renderModules,
  resolveModules,
} from "better-supabase/sql";

import config from "../better-supabase.config.ts";

const layout = moduleLayout(resolveConfig(config, process.cwd()));
const modules = resolveModules(["audit", "jobs"], layout).map((m) => m.name);
const files = renderModules(["audit", "jobs"], layout);
```

`compileReadSets` turns the read sets in `generated.ts` into the SQL views and
functions that `better-supabase gen` writes. `moduleBody(name, layout)`
returns one module's SQL without its header, `customContracts` the functions
custom-mode modules must provide, and `moduleFileVersion(contents)` the version
and mode in a file's header.

`moduleFilePaths(names, layout)` maps each module a set of names pulls in to its
schema and test paths without rendering it. `modulePermissionKeys(blocks, names)`
lists the permission keys those modules check, with the action, the key after
`sql.modules.<module>.permissions` and whether a tenant or a platform check asks for
it. Under `sql.modules.access.model: 'provider'`, `sql add` and doctor (BS411) check
each key against the provider's `permissions`; a script can do the same:

```ts title="scripts/check-module-keys.ts"
import { resolveConfig } from "better-supabase/config";
import { modulePermissionKeys } from "better-supabase/sql";

import config from "../better-supabase.config.ts";

const { sql, authorization } = resolveConfig(config, process.cwd());
const scopeOnly = new Set(
  (authorization?.permissions ?? [])
    .filter((entry) => entry.sqlComplete === true)
    .map((entry) => entry.key),
);
for (const { module, action, key } of modulePermissionKeys(
  sql.modules,
  sql.moduleNames,
)) {
  if (!scopeOnly.has(key)) console.error(`${module}.${action}: ${key}`);
}
```

# SSO

> Verified email domains with auto-join, SAML providers per organization, SSO enforcement in the access token hook, and a SCIM 2.0 endpoint that provisions memberships.

Source: https://bettersupabase.com/docs/blocks/sso

The `sso` block gives an organization the identity features enterprise
customers ask for. An owner claims an email domain and proves it with a DNS
TXT record. Users who sign up with an address at a verified domain can join
the organization on their own, the organization can bring its SAML identity
provider (through Supabase Auth's SAML support), and it can require that
provider for everyone at the domain. Its identity provider can also create,
update and remove members through SCIM 2.0.

```bash
better-supabase sql add sso   # adds tenant and access as well
```

The module assigns roles by name. Under the `roles` and `catalog` access
models it takes them from the config. Under the `provider` and `custom`
models the roles live elsewhere, so list the roles SCIM and auto-join may
assign in `sql.modules.sso.options.roleOrder`, highest first; `sql add` stops
without it. A memberships table that stores role ids reads them through
`sql.modules.tenant.options.roleThrough`, which an authorization provider's
`roleSources` fill in.
Setting a domain's `auto_join_role` also needs `can_assign(tenant, role)` for
the caller, which is the provider's `canAssign` under the provider model
(or `sql.modules.access.functions.canAssign`), so a manager can't make a domain
hand out a role they couldn't grant themselves (`SSO_ROLE_FORBIDDEN`).

| Table                        | Holds                                                                                    |
| ---------------------------- | ---------------------------------------------------------------------------------------- |
| `organization_domains`       | `domain`, the verification token, `verified_at`, `auto_join_role` and `enforce_sso`      |
| `organization_sso_providers` | The Supabase Auth SAML provider's `id`, its `metadata_url` and the `domains` it signs in |
| `scim_users`                 | The SCIM User resources, with the auth user they are linked to                           |
| `scim_groups`                | The SCIM Group resources                                                                 |
| `scim_group_members`         | Which users are in which group                                                           |

| Permission   | Lets a member                                                    | Default roles |
| ------------ | ---------------------------------------------------------------- | ------------- |
| `sso.manage` | claim domains, set auto-join and enforcement, add SAML providers | `owner`       |

A verified domain belongs to one organization; a second organization can
claim it, but verifying it fails with `SSO_DOMAIN_TAKEN`.

## Domains [#domains]

```ts title="app/settings/domains/actions.ts"
import { createSso, rpcTransport } from "better-supabase/blocks/sso";

const sso = createSso({ transport: rpcTransport(supabase) });

const domain = await sso.addDomain(organizationId, "acme.com").orThrow();
// Show domain.record: a TXT record the customer adds to their DNS.
// { type: "TXT", name: "_better-supabase.acme.com", value: "better-supabase-domain-verification=…" }

await sso.domains(organizationId);
await sso.updateDomain(domain.id, { autoJoinRole: "member", enforceSso: true });
await sso.removeDomain(domain.id);
```

Verification runs as the service role, because the database can't look up
DNS. `createSsoAdmin` resolves the TXT record over DNS over HTTPS (Cloudflare
by default, through `dohResolver`) and marks the domain verified when the
token is there. Pass the member who asked as `actorId`; the call fails with
`SSO_DOMAIN_NOT_FOUND` unless they have `sso.manage` in the domain's
organization:

```ts title="app/settings/domains/verify.ts"
import { createPostgres } from "better-supabase/postgres";
import { createSsoAdmin, sqlTransport } from "better-supabase/blocks/sso";

const postgres = createPostgres({ connectionString: env.SUPABASE_DB_URL });
const admin = createSsoAdmin({ transport: sqlTransport(postgres.admin) });

const result = await admin.verifyDomain(domainId, { actorId: user.id });
// SSO_DOMAIN_RECORD_MISSING until the record is published
```

A verified domain emits `organization.domain_verified` through the outbox.
Change the record's prefix with `sql.modules.sso.options.txtPrefix`.

## Auto-join [#auto-join]

With `autoJoinRole` set, a user whose confirmed email is at the domain
becomes a member with that role when they sign up, or when they confirm or
change their email. Existing members keep their role. The owner role can't
be an auto-join role.

## SAML [#saml]

Give `createSsoAdmin` the project's URL and secret key to manage providers in
Supabase Auth. Each provider is recorded against the organization and may
only sign in its verified domains:

```ts title="app/settings/sso/actions.ts"
const admin = createSsoAdmin({
  transport: sqlTransport(postgres.admin),
  auth: { url: env.SUPABASE_URL, secretKey: env.SUPABASE_SECRET_KEY },
});

const provider = await admin
  .addSamlProvider(
    organizationId,
    { metadataUrl: "https://idp.acme.com/metadata", domains: ["acme.com"] },
    { actorId: user.id },
  )
  .orThrow();

await admin.updateSamlProvider(
  provider.id,
  { domains: ["acme.com", "acme.io"] },
  { actorId: user.id },
);
await admin.removeSamlProvider(provider.id, { actorId: user.id });
```

Pass `metadataXml` instead of `metadataUrl` when the provider has no metadata
URL. A domain a provider uses can't be removed (`SSO_DOMAIN_IN_USE`).

On the sign-in page, `domainFor` tells you whether an address belongs to a
provider. It works signed out:

```ts title="app/login/actions.ts"
const match = await sso.domainFor(email).orThrow();
if (match) await supabase.auth.signInWithSSO({ providerId: match.providerId });
```

## Enforcing SSO [#enforcing-sso]

With `enforceSso` on, users at the domain must sign in through SAML.
`sso_access_token_check` returns the event unchanged, or an error Supabase
Auth shows instead of issuing a token when the user's domain enforces SSO
and they signed in some other way. Call it first in your custom access token
hook:

```sql
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
set search_path = ''
as $$
declare
  uid uuid := (event ->> 'user_id')::uuid;
begin
  event := better_supabase.sso_access_token_check(event);
  if event ? 'error' then
    return event;
  end if;
  return jsonb_set(event, '{claims,memberships}', better_supabase.membership_claims(uid));
end
$$;

grant execute on function public.custom_access_token_hook(jsonb) to supabase_auth_admin;
```

The check runs on every token, including refreshes, so a user who signed in
with a password before the switch loses access at their next refresh.

## SCIM [#scim]

`scimHandler` serves SCIM 2.0 (RFC 7643 and RFC 7644): `/Users` and
`/Groups` with filters, paging, `attributes`, PATCH, ETags and `/.search`,
and the discovery endpoints `/ServiceProviderConfig`, `/ResourceTypes` and
`/Schemas`. The identity provider authenticates with an
[API key](/docs/blocks/api-keys) of the organization that has the `scim`
scope:

```ts title="app/scim/v2/[...path]/route.ts"
import { createApiKeys } from "better-supabase/blocks/api-keys";
import { scimHandler, sqlTransport } from "better-supabase/blocks/sso";

const transport = sqlTransport(postgres.admin);
const handler = scimHandler({
  transport,
  keys: createApiKeys({ transport }),
  basePath: "/scim/v2",
});

export {
  handler as GET,
  handler as POST,
  handler as PUT,
  handler as PATCH,
  handler as DELETE,
};
```

Give the identity provider `https://app.example.com/scim/v2` as the tenant
URL and the key as the Bearer token. `/Me` and `/Bulk` answer 501.

A SCIM user is linked to the auth user with the same email once that email
is confirmed, and only when its domain is verified for the organization, so
another organization's identity provider can't pull in outside accounts. A
linked, active user is a member; a deactivated or deleted one is removed.
The owner's membership is never changed.

The member's role comes from their groups. A group named like an assignable
role (`admin`) grants that role; map other names with `groupRoles`. When a
user is in several groups, the highest role in `roleOrder` wins, and a user
in none gets `defaultRole`:

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      sso: {
        options: {
          defaultRole: "member",
          roleOrder: ["admin", "member"],
          groupRoles: { "Engineering Leads": "admin" },
        },
      },
    },
  },
});
```

Memberships SCIM changes emit `organization.member_added`,
`organization.role_changed` and `organization.member_removed`, like the
ones members change.

## Functions [#functions]

| Function                                                | Granted to                            | Does                                                  |
| ------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------- |
| `add_organization_domain(tenant, domain)`               | `authenticated`                       | Claims a domain and returns its TXT record            |
| `list_organization_domains(tenant)`                     | `authenticated`, `service_role`       | The organization's domains                            |
| `update_organization_domain(id, auto_join, enforce)`    | `authenticated`, `service_role`       | Sets auto-join and enforcement on a verified domain   |
| `remove_organization_domain(id)`                        | `authenticated`, `service_role`       | Removes a domain no provider uses                     |
| `verify_organization_domain(id, actor)`                 | `service_role`                        | Marks a domain verified after the DNS check           |
| `register_sso_provider(tenant, provider, domains, ...)` | `service_role`                        | Records a Supabase Auth provider for the organization |
| `list_sso_providers(tenant)`                            | `authenticated`, `service_role`       | The organization's providers                          |
| `sso_domain_for(email)`                                 | `anon`, `authenticated`               | The provider for an address at a verified domain      |
| `sso_access_token_check(event)`                         | `service_role`, `supabase_auth_admin` | The event, or an error when the domain enforces SSO   |
| `scim_save_user`, `scim_save_group` and the rest        | `service_role`                        | The storage behind `scimHandler`                      |

# Durable streams

> Resumable output for chats, workflows and agents, stored in Postgres or Redis, with a cancel flag the writer reads on its next write.

Source: https://bettersupabase.com/docs/blocks/streams

`better-supabase/streams` stores the output of a long-running response
(a model generation, a workflow run, an agent step) as ordered text chunks.
A reader that disconnects reconnects with the number of chunks it already
has and gets the rest, live or after the writer finished. A reader can also
ask the writer to stop, and the writer learns on its next write.

Two stores implement the `StreamStore` interface:

| Store                          | Where chunks live                                 | Wakes readers with                       |
| ------------------------------ | ------------------------------------------------- | ---------------------------------------- |
| `postgresStreamStore(options)` | the `streams` SQL module                          | a payload-free Realtime ping, or polling |
| `redisStreamStore(options)`    | Redis lists, from `better-supabase/streams/redis` | a Redis pub/sub message, or polling      |

## Postgres [#postgres]

```bash
pnpm better-supabase sql add streams
```

The module adds `streams` and `stream_chunks` tables and these functions:

| Function                                    | Who can call it           | What it does                                                                              |
| ------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------- |
| `stream_open(id, owner, tenant, kind, ttl)` | `service_role`            | Creates the stream; returns `false` when it existed                                       |
| `stream_append(id, from_idx, chunks)`       | `service_role`            | Stores the chunks at their indexes, skips indexes already stored, returns the cancel flag |
| `stream_read(id, from_idx, max)`            | the owner, `service_role` | Chunks from an index, through RLS                                                         |
| `stream_status(id)`                         | the owner, `service_role` | The chunk count and the closed and cancelled flags                                        |
| `stream_close(id)`                          | `service_role`            | Marks the stream finished                                                                 |
| `stream_cancel(id)`                         | the owner, `service_role` | Sets the cancel flag                                                                      |
| `purge_streams(older_than, batch)`          | `service_role`            | Deletes expired streams and closed ones older than `older_than`                           |

`stream_append` fails with the hint `STREAM_NOT_FOUND`, `STREAM_CLOSED` or
`STREAM_GAP` (an index past the end). A retried batch is harmless, because
indexes already stored are skipped.

```ts title="src/lib/streams.ts"
import { postgresStreamStore, sqlTransport } from "better-supabase/streams";

export const streams = postgresStreamStore({
  transport: sqlTransport(postgres.asService()),
});
```

After each batch the writer pings the stream's private Realtime topic
(`stream:<id>` by default, `sql.modules.streams.options.topic` changes the
prefix) without the chunk in the payload. A reader that passes `realtime`
(a Supabase client) wakes on the ping and reads the new chunks through RLS;
without it, the reader polls every `pollMs` (250 ms). Set `wake: "poll"` to
skip the pings for apps near the Realtime message quota.

## Redis [#redis]

```bash
pnpm add redis
```

```ts title="src/lib/streams.ts"
import { redisStreamStore } from "better-supabase/streams/redis";

export const streams = redisStreamStore({ url: process.env.REDIS_URL });
```

With `url`, the store loads `redis` the first time it is used and opens a
second connection for pub/sub. Pass `client` (and `subscriber`) instead to
use connections the app already has; any client with the node-redis method
names works. `keyPrefix` (`bs:stream` by default) namespaces the keys, and
`ttl` (one day) sets their expiry. A Redis store has no owner check: the
route that resumes a stream must check the caller first.

## Writing and resuming [#writing-and-resuming]

`teeToStore` copies a stream into the store while passing it through to the
live response:

```ts title="app/api/chat/route.ts"
import { resumeFromStore, teeToStore } from "better-supabase/streams";
import { after } from "next/server";

const { stream, persisted } = teeToStore(streams, chatId, output, {
  owner: userId,
  kind: "chat",
  onCancel: () => controller.abort(),
});
after(() => persisted);
return new Response(stream.pipeThrough(new TextEncoderStream()));
```

`persisted` keeps writing after the live client disconnects, so hand it to
`after` or `waitUntil`. Chunks are written in batches (every 100 ms or
4 KB). `writeToStore` does the same for a stream the caller already sends
elsewhere.

A reconnecting client sends the number of chunks it has:

```ts title="app/api/chat/[id]/stream/route.ts"
const rest = await resumeFromStore(streams, id, { fromIdx }).orThrow();
if (rest === undefined) return new Response(null, { status: 204 });
return new Response(rest.pipeThrough(new TextEncoderStream()));
```

`resumeFromStore` returns `undefined` when there is nothing to resume: no
such stream, or a closed one the client read to the end.

## Cancelling [#cancelling]

`streams.cancel(id)` sets the flag. The writer reads it on its next append
and calls `onCancel` once, so abort the generation there. Call `cancel` from
a route the owner can reach; with the Postgres store an owner can also call
`stream_cancel` through RLS.

## Cleaning up [#cleaning-up]

Run `streams.purge({ olderThan: "1 day" })` from a [job](/docs/blocks/jobs)
schedule, or schedule `purge_streams` with pg\_cron. It resolves with the
number of streams deleted.

## Your own store [#your-own-store]

`StreamStore` has `apiVersion: 1`. Run `testStreamStore` from
`better-supabase/testing` against your implementation; see
[Interfaces](/docs/extending/interfaces#streamstore).

# Usage and quotas

> Count usage per tenant and meter with idempotent increments, enforce quotas per tenant or plan in RLS and RPCs, and report usage to Stripe meters.

Source: https://bettersupabase.com/docs/blocks/usage

The `usage` block counts what each tenant uses (API calls, seats, generated
documents) and enforces quotas on it. Counters are kept per tenant, meter and
UTC day, so one meter can have a daily quota for one plan and a monthly one
for another.

```bash
better-supabase sql add usage   # adds tenant and access as well
```

| Table            | Holds                                                                                     |
| ---------------- | ----------------------------------------------------------------------------------------- |
| `usage_counters` | `value` and `reported_value` per `(organization_id, meter, day)`                          |
| `usage_events`   | One row per idempotency key, so a retried increment counts once                           |
| `usage_quotas`   | `limit` and `period` (`day`, `week`, `month`, `year` or `billing`) per tenant or per plan |

Members with `usage.read` (the default `admin` role) can read the counters,
the tenant's quotas and the status that `current` and `overview` return.
Other members get a `forbidden` result with the hint `USAGE_FORBIDDEN`. Recording usage (`record`, `consume` and the batch
variants) needs `usage.record` (also in the default `admin` role) or the
service role, so a member can't inflate or use up the tenant's quota from
the browser. Only the service role writes quotas.

## Quotas [#quotas]

A quota row names either a tenant or a plan. A tenant row overrides the plan
rows. The plan `*` applies to every tenant; with the
[`entitlements`](/docs/blocks/entitlements) module installed, any other plan
matches tenants with that entitlement key, and the highest limit wins. When
`entitlements.source` is a plan catalog, a quota's plan also matches the
tenant's active plan key (`better_supabase.tenant_plans(tenant)`), so
`('pro', 'api_calls', 50000, 'month')` applies to every tenant on `pro`.

```sql
insert into better_supabase.usage_quotas (plan, meter, "limit", period) values
  ('*', 'api_calls', 1000, 'month'),
  ('pro', 'api_calls', 100000, 'month');
insert into better_supabase.usage_quotas (organization_id, meter, "limit", period)
  values ('8d1c...', 'api_calls', 250000, 'month');
```

A `null` limit is unlimited. It wins over every other plan row, so an
`enterprise` plan row with a `null` limit lifts the `*` limit for those
tenants, and a tenant row with a `null` limit lifts the plan limits for one
tenant. `consume` and `within_quota` always pass for an unlimited meter, and
`current` returns `unlimited: true` with no `limit` or `remaining`. Unlike a
meter with no quota row at all, the unlimited quota still sets the meter's
`period`.

```sql
insert into better_supabase.usage_quotas (plan, meter, "limit", period)
  values ('enterprise', 'api_calls', null, 'month');
```

### Billing periods [#billing-periods]

Calendar periods reset at the start of the UTC day, week, month or year.
Products billed through Stripe usually reset quotas with the invoice instead,
which starts on the subscription's own day. Give such quotas the period
`billing` and write a `usage_billing_period(tenant)` function (in the hooks
schema, `public` by default) that returns the current window:

```sql
create function public.usage_billing_period(tenant uuid)
returns table (starts_at timestamptz, ends_at timestamptz)
language sql stable security definer set search_path = '' as $$
  select to_timestamp(s.current_period_start), to_timestamp(s.current_period_end)
  from stripe.subscriptions s
  join better_supabase.billing_customers c on c.stripe_customer_id = s.customer
  where c.organization_id = tenant and s.status in ('active', 'trialing')
  order by s.created desc
  limit 1
$$;
```

Any source works: a column on your subscriptions table, or a fixed anchor
day. Counters are kept per UTC day, so a window counts the whole days from
`starts_at` up to the day of `ends_at`. Without the function, or when it
returns no row, a `billing` quota falls back to the calendar month.
The function only returns the current window, so a day reported after its
period ended (an overage report that ran late, say) counts in the period that
held it, taking earlier periods to be as long as the current one.
`usage_status` and `current` return `startsAt` and `resetsAt` for the window,
and `retryAfter` counts down to its end.

### The meter catalog [#the-meter-catalog]

`options.meters` lists the meters with a unit, category and label for
display. With it, `record_usage` and `consume_quota` refuse other meter names
(`USAGE_METER_UNKNOWN`), `usage_status` returns the meter's fields, and
`usage.meters()` (`usage_meters()`) reads the catalog for a usage page:

```ts title="better-supabase.config.ts"
usage: {
  options: {
    meters: {
      "ai.tokens": { unit: "tokens", category: "ai", label: "AI tokens" },
      "api.requests": { unit: "requests", category: "api" },
      "storage.bytes": { unit: "bytes", category: "storage" },
    },
  },
},
```

A catalog that changes without a deploy (meters an admin adds, products
from a plan table) can live in a table instead. Point `options.meters` at it
with `table` and the column names; `unit`, `category`, `label` and `active`
are optional, and only rows where `active` is true are meters:

```ts title="better-supabase.config.ts"
usage: {
  options: {
    meters: {
      table: "public.meters",
      key: "slug", // default key
      unit: "unit",
      label: "title",
      active: "is_active",
    },
  },
},
```

`usage_meters()` then reads the table on every call, so a new row is a
meter right away.

### Weighted quantities [#weighted-quantities]

`record` and `consume` take a `quantity`, so a meter can count something
other than calls. For credits, record the credits a call costs, such as
`tokens * creditsPerToken`, on a `credits` meter with a quota in credits;
the counter, the quota and the Stripe report all work on that number. Keep
the raw units on their own meter when you also want to show them.

Quantities, counters and limits are `numeric`, so a meter can count
fractions, such as GB-hours or credits with decimals, against a fractional
quota:

```ts
await usage.record(organizationId, "gb_hours", { quantity: 0.25 });
```

`reportUsageToStripe` sends the change as it is; when your Stripe meter
expects whole numbers, record in whole units (tokens, cents) instead.
`within_quota` takes a whole `quantity` (1 by default), as policies check
rows.

Use `within_quota` in a policy to stop inserts once the quota is used up:

```sql
create policy "projects_insert" on public.projects for insert to authenticated
  with check (better_supabase.within_quota(organization_id, 'projects'));
```

Any member of the tenant gets the answer, so the policy works for members
without `usage.read`. For a caller outside the tenant `within_quota` returns
false, so nobody can probe another tenant's usage with it.

## Recording and consuming [#recording-and-consuming]

```ts title="app/api/documents/route.ts"
import { createUsage, sqlTransport } from "better-supabase/blocks/usage";

const usage = createUsage({ transport: sqlTransport(postgres.asUser(claims)) });

// Counts the call and fails when it would go over the quota.
const consumed = await usage.consume(organizationId, "documents", {
  idempotencyKey: requestId,
});
if (!consumed.ok) return problemResponse(consumed.error);

await usage.record(organizationId, "api_calls"); // metering only, no check
const { used, limit, remaining, resetsAt } = await usage
  .current(organizationId, "documents")
  .orThrow();
```

`consume` locks the day's counter, so two concurrent calls can't both take the
last unit. When the quantity doesn't fit, nothing is recorded and the result
is a `quota_exceeded` [`DbError`](/docs/concepts/results) with status 429,
the `meter`, its `limit` and `retryAfter`, the seconds until the period
resets. `problemResponse` turns it into Problem Details with a `Retry-After`
header. In SQL, `consume_quota` raises it with SQLSTATE `BSQ29`, so an RPC
that calls it fails the same way.

### Every meter of a tenant [#every-meter-of-a-tenant]

A usage page that shows every meter calls `overview` instead of `current`
once per meter. It returns the status of each meter in the catalog, each
meter a quota applies to and each meter the tenant has used, sorted by
meter, in one request (`usage_overview(tenant)` in SQL). Like `current`, it
needs `usage.read`.

```ts
const meters = await usage.overview(organizationId).orThrow();
// [{ meter: "api_calls", used: 420, limit: 1000, remaining: 580, unlimited: false, ... }]
```

### Several meters at once [#several-meters-at-once]

`recordMany` and `consumeMany` record several meters in one transaction, all
or none, such as the input and output tokens of one model call.
`consumeMany` checks each meter's quota first; when one doesn't fit, nothing
is recorded and the result is `quota_exceeded`. The idempotency key covers
the batch, and `source`, `metadata` and `actor` apply to every entry:

```ts
await usage
  .consumeMany(
    organizationId,
    [
      { meter: "input_tokens", quantity: inputTokens },
      { meter: "output_tokens", quantity: outputTokens },
    ],
    { idempotencyKey: requestId, source: "feature:chat" },
  )
  .orThrow();
// { recorded: true, today: { input_tokens: 1200, ... }, used: { input_tokens: 48000, ... } }
```

`record`, `consume` and the batches return `today`, the UTC day's usage, and
`used`, the usage in the quota's current period (the month without a quota),
the same number `current` returns.

In SQL, `record_usage_batch(tenant, entries, idempotency_key, check)` takes
`entries` as a JSON array of `{ meter, quantity }`.

## Usage history [#usage-history]

A usage page shows who and what used a meter. Set
`sql.modules.usage.options.history` to `true` and every recorded quantity also
goes to `usage_history`, with the user who used it and the `source` and
`metadata` you pass:

```ts
await usage.consume(organizationId, "tokens", {
  quantity: tokens,
  source: "feature:summary",
  metadata: { documentId },
});

const entries = await usage
  .history(organizationId, { meter: "tokens", limit: 50 })
  .orThrow();
// [{ id, meter, quantity, actor, source, metadata, recordedAt }], newest first
const byUser = await usage.breakdown(organizationId, "tokens").orThrow();
// [{ actor, source, quantity }] in the quota's current window, largest first
```

The actor is the caller's user id. A service transport recording on a user's
behalf passes `actor`; other callers can't set it. Reading the history and
the breakdown needs `usage.read`, and `history` pages with `cursor` (the
last entry's `id`). `better_supabase.purge_usage_history(older_than, batch)`
deletes old entries (400 days by default), and
`better_supabase.purge_usage_events(older_than, batch)` deletes idempotency
keys (30 days by default); schedule both like the other purges. A retry
that arrives after its key is purged counts again. Without the option, both reads return `[]`.

## Reporting to Stripe [#reporting-to-stripe]

`reportUsageToStripe` sends the usage not yet reported as
[Stripe meter events](https://docs.stripe.com/billing/subscriptions/usage-based/recording-usage),
one per counter and change. Run it from a job or a cron route with a
service-role transport:

```ts title="app/api/cron/usage/route.ts"
import {
  reportUsageToStripe,
  sqlTransport,
} from "better-supabase/blocks/usage";

export async function GET() {
  const { reported, skipped } = await reportUsageToStripe({
    transport: sqlTransport(postgres),
    stripe: { secretKey: env.STRIPE_SECRET_KEY },
    eventName: (meter) => (meter === "api_calls" ? "api_requests" : undefined),
  });
  return Response.json({ reported, skipped });
}
```

The event identifier is the tenant, meter, day and new total, so a run that
fails between sending and marking sends the same event again and Stripe drops
the duplicate. The Stripe customer comes from `tenant_stripe_customer()` in
the `entitlements` module unless you pass `customer`, which reads it from
anywhere, such as your own customers table:

```ts
customer: async (organizationId) =>
  (await db.billingCustomers.findFirst({ where: { organizationId } }).orThrow())
    ?.stripeCustomerId,
```

When a plan includes an allowance and Stripe bills only what goes over it,
pass `overage: true`: in each quota window, usage up to the tenant's quota
is marked reported without an event, and only the units above it are sent.
A meter without a quota sends everything, and a meter with an unlimited quota
sends nothing. `unreported_usage` returns each
counter's `included` quota and `window_before` (the earlier days' usage in
that window) for the calculation; usage that arrives late on an earlier day
is counted with that day.

Counters without a
customer or an event name are skipped and stay unreported. They don't hold up
the rest: once a run meets a meter without an event name or a tenant without
a customer, it asks `unreported_usage` again with that meter in `skip_meters`
or that tenant in `skip_tenants`, so `batch` still fills with counters it can
send. `stripe` is an
optional peer; pass a Stripe client instead of `secretKey` to use your own.

## Functions [#functions]

| Function                                                                   | Granted to                      | Returns                                                                                                                 |
| -------------------------------------------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `record_usage(tenant, meter, quantity, key, source, metadata, actor)`      | `authenticated`, `service_role` | `{ recorded, used }`                                                                                                    |
| `consume_quota(tenant, meter, quantity, key, source, metadata, actor)`     | `authenticated`, `service_role` | the same, or raises `BSQ29`                                                                                             |
| `within_quota(tenant, meter, quantity)`                                    | `authenticated`, `service_role` | whether `quantity` more fits, false outside the tenant                                                                  |
| `record_usage_batch(tenant, entries, key, check, source, metadata, actor)` | `authenticated`, `service_role` | `{ recorded, used }` with `used` per meter, or raises `BSQ29`                                                           |
| `usage_status(tenant, meter)`                                              | `authenticated`, `service_role` | `{ meter, used, limit, remaining, unlimited, period, starts_at, resets_at }`, plus the catalog fields, for `usage.read` |
| `usage_overview(tenant)`                                                   | `authenticated`, `service_role` | `usage_status` of every meter of the tenant, by meter, for `usage.read`                                                 |
| `usage_meters()`                                                           | everyone                        | the meter catalog                                                                                                       |
| `usage_history(tenant, meter, max_rows, before_id)`                        | `authenticated`, `service_role` | the history entries, for `usage.read`                                                                                   |
| `usage_breakdown(tenant, meter)`                                           | `authenticated`, `service_role` | `[{ actor, source, quantity }]` in the current window                                                                   |
| `usage_window(tenant, period)`                                             | `service_role`                  | the current window's `starts_at` and `ends_at`                                                                          |
| `unreported_usage(max_rows, skip_meters, skip_tenants)`                    | `service_role`                  | counters with `value > reported_value`                                                                                  |
| `mark_usage_reported(tenant, meter, day, value)`                           | `service_role`                  | whether the counter exists                                                                                              |

# Vector search

> Nearest-neighbour search with pgvector that respects RLS and still returns k rows per tenant.

Source: https://bettersupabase.com/docs/blocks/vector-search

Semantic search over embeddings is an `order by embedding <=> query limit k`.
In a multi-tenant app, RLS adds `where org_id = ...` to that query, and an
HNSW index can't apply it while scanning: it finds the `k` nearest rows
across all tenants, then RLS drops the ones the caller can't see. A tenant
with a small share of the data often gets fewer than `k` results, or none.

pgvector 0.8 fixed this with
[iterative index scans](https://github.com/pgvector/pgvector#iterative-index-scans):
with `hnsw.iterative_scan` on, the scan continues until `k` visible rows are
found. The `vector-search` SQL module writes one search function per table
with it set, and `db.$search` calls it.

## Setup [#setup]

List the embedding columns:

```ts title="better-supabase.config.ts"
export default defineConfig({
  vectorSearch: {
    chunks: "embedding", // cosine distance
    "docs.pages": { column: "embedding", distance: "inner_product" }, // or 'l2'
  },
  sql: { modules: ["vector-search"] },
});
```

```bash
better-supabase sql sync
supabase db schema declarative sync -f vector_search
```

For `chunks` this writes:

```sql
create or replace function public.search_chunks(query extensions.vector, k integer default 10)
returns setof public.chunks
language plpgsql stable security invoker
set search_path = ''
as $$
declare
  previous_scan text := current_setting('hnsw.iterative_scan', true);
begin
  perform set_config('hnsw.iterative_scan', 'strict_order', true);
  return query select t.* from public.chunks t
  where t.embedding is not null
  order by t.embedding operator(extensions.<=>) query
  limit least(greatest(k, 1), 1000);
  perform set_config('hnsw.iterative_scan', coalesce(previous_scan, 'off'), true);
end;
$$;
```

The function turns on `hnsw.iterative_scan` in its body and restores the
caller's value afterwards, instead of in a `set` clause. A `set` clause for a
pgvector setting needs the pgvector library loaded in the session that runs
`create function`, and a migration session that hasn't used a vector yet
fails with `permission denied to set parameter "hnsw.iterative_scan"` for
any role that isn't a superuser, which includes `postgres` on Supabase.

The functions use the schema pgvector is installed in. `sql sync` reads it
from the `create extension ... vector ... schema` statement in your schema
files or migrations, and falls back to `extensions`, the Supabase default.
Set `sql.modules.vector-search.options.schema` when pgvector is installed
another way:

```ts title="better-supabase.config.ts"
sql: {
  modules: { "vector-search": { options: { schema: "public" } } },
},
```

The function is `security invoker`, so the caller's RLS policies run inside
the scan. It is granted to `authenticated` and `service_role`; grant it to
`anon` yourself for public search. Create the index with the operator class
that matches the distance:

```sql
create index on public.chunks using hnsw (embedding extensions.vector_cosine_ops);
```

## Searching [#searching]

```ts
const embedding = await embed(question); // number[]
const result = await ctx.db.$search("chunks", {
  vector: embedding,
  k: 8,
  select: ["id", "content", "documentId"],
});
```

* Rows come back nearest first, typed from the generated schema, in your
  configured casing. `select` and `include` work as in `findMany`.
* `where` filters the `k` rows the search returns, so it can return fewer. Put
  filters that should shape the search (the tenant, a document set) in RLS.
* `vector` is a `number[]` or pgvector text (`'[0.1,0.2]'`). It is sent in a
  POST body: embeddings are too long for a URL. So a search counts toward
  [write rate limits](/docs/blocks/sql#rate-limiting-writes) and goes to the
  primary with [read replicas](/docs/guides/read-replicas).
* It runs over PostgREST and over [`better-supabase/postgres`](/docs/auth/postgres)
  alike. A table without a search function fails with a hint to add it to
  `vectorSearch`.

## Scores [#scores]

`score: true` adds `$score` to each row: the similarity (`1 - distance` for
cosine, `1 / (1 + distance)` for `l2`, the inner product for
`inner_product`), or the fused and boosted score below. Every entry also gets
`search_<table>_scores`, which returns the ids and scores; `db.$search` reads
it, then the rows through the table, so RLS and `where` apply to both. It
needs a one-column primary key, `id` unless `key` names another.

```ts
const rows = await db
  .$search("chunks", { vector, k: 8, score: true })
  .orThrow();
rows[0].$score; // number
```

## Hybrid ranking, boosts, filters and order [#hybrid-ranking-boosts-filters-and-order]

An entry can take more options:

```ts title="better-supabase.config.ts"
vectorSearch: {
  knowledge_chunks: {
    column: "embedding",
    type: "halfvec", // the column is halfvec(1536)
    hybrid: { tsvector: "content_tsv", config: "english" },
    boost: "t.retrieval_priority",
    prefilter: ["collection_id", "organization_id"],
  },
},
```

| Option      | What it does                                                                                                                                                                  |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`      | `halfvec` for half-precision columns; `vector` by default                                                                                                                     |
| `hybrid`    | ranks the `tsvector` column against `text` with `websearch_to_tsquery` and fuses both rankings by reciprocal rank (`k`, 60 by default)                                        |
| `boost`     | an expression over the row `t` the score is multiplied by, such as a priority column                                                                                          |
| `prefilter` | columns `filter` narrows before ranking, so filters shape the scan instead of trimming the `k` rows                                                                           |
| `key`       | the primary key column the scores function returns; `id` by default                                                                                                           |
| `predicate` | a SQL condition over the row `t` every candidate meets before ranking: ranges and related rows, such as `t.expires_at > now()` or an `exists` on a parent that isn't disabled |
| `boostMode` | `multiply` (default) or `add`, how `boost` combines with the score                                                                                                            |
| `order`     | a SQL `order by` list over `t` that breaks score ties, such as `t.created_at desc`                                                                                            |

With any of `hybrid`, `boost`, `prefilter`, `predicate` or `order`, the functions take
`filter jsonb` and `text_query text` as well, and rank four times `k`
candidates from each side before they keep `k`:

```ts
const rows = await db
  .$search("knowledgeChunks", {
    vector: embedding,
    text: question,
    k: 8,
    filter: { collection_id: ids, organization_id: [orgId, null] },
    score: true,
  })
  .orThrow();
```

On a `hybrid` entry, `vector` can be left out (or `null`) with `text`: the
search then ranks by full-text alone. That keeps search working when the
embedding call fails or times out:

```ts
const embedding = await embed(question).catch(() => null);
const rows = await db
  .$search("knowledgeChunks", { vector: embedding, text: question, k: 8 })
  .orThrow();
```

`filter` is keyed by database column name. A value matches the column; a list
matches any of its values, and `null` in it matches rows where the column is
null, such as shared rows without an organization.

`strict_order` returns rows in exact distance order. For large tables where
approximate order is enough, change the setting in the function to
`relaxed_order`, and raise `hnsw.max_scan_tuples` if a tenant's rows are
very sparse.

## Example [#example]

The Next.js example stores a 3-dimensional `notes.embedding` and lists the
notes nearest to the latest one on its customers page
(`src/features/notes/note-queries.ts`). It needs no embedding model: the
query vector is a stored embedding.

## Custom executors [#custom-executors]

`db.$search` reads through `SelectOp.source`. An executor opts in with
`functionSources: true`; `testExecutor` then checks that it reads the function
rather than the table. See [Interfaces](/docs/extending/interfaces#executor).

# Waitlist

> A waitlist with positions and approvals, hashed invite codes with use limits and an optional organization, and a before-user-created hook that makes sign-up invite-only.

Source: https://bettersupabase.com/docs/blocks/waitlist

The `waitlist` block makes sign-up invite-only. People join the waitlist and
see their place; staff approve them. Invite codes let someone skip the line,
and a code can also add its user to an organization with a role. A
Supabase Auth before-user-created hook rejects every sign-up that is neither
approved nor carries a valid code.

```bash
better-supabase sql add waitlist   # adds tenant and access as well
```

| Table                     | Holds                                                                                                      |
| ------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `waitlist_entries`        | `email`, `status` (`waiting`, `approved`, `rejected`, `joined`), `position`, `referrer`, `metadata`        |
| `invite_codes`            | The code's SHA-256, its first four characters, `max_uses`, `uses`, `expires_at`, `organization_id`, `role` |
| `invite_code_redemptions` | Who used which code                                                                                        |

| Permission                                                 | Lets                                                   | Default roles          |
| ---------------------------------------------------------- | ------------------------------------------------------ | ---------------------- |
| `waitlist.manage`                                          | platform staff see and decide entries and create codes | platform roles with it |
| `members.invite` (the `invitations` module's `invite` key) | a member create codes for their own organization       | `owner`, `admin`       |

The service role can do everything. Only the code's hash is stored, so a
code is shown once, when it is created.

## Joining [#joining]

`join` works signed out. It returns the address's place among the waiting
entries, and the same answer when the address joins again. Rejected entries
read as `waiting`, so the form never tells someone they were turned down.

```ts title="app/waitlist/actions.ts"
"use server";

import { createWaitlist, rpcTransport } from "better-supabase/blocks/waitlist";

export async function join(email: string) {
  const waitlist = createWaitlist({ transport: rpcTransport(supabase) });
  return waitlist.join(email, { referrer: "launch-post" }).orThrow();
  // { position: 412, status: "waiting" }
}
```

`join_waitlist` is open to `anon`, so rate-limit the route that calls it. It
never tells a caller what happened to an address: called with a user's
session or signed out, it always returns `status: "waiting"`, and for an
address that was approved, rejected or already signed up it returns the place
a new address would get. Only the service role sees the real status, with
`position` undefined once the entry left the line.

## Approving [#approving]

```ts title="app/admin/waitlist/actions.ts"
const waitlist = createWaitlist({ transport: sqlTransport(postgres.admin) });

const entries = await waitlist
  .entries({ status: "waiting", limit: 50 })
  .orThrow();
await waitlist.approve(entries[0].id);
await waitlist.reject(entries[1].id);
```

Approving emits `waitlist.approved` through the outbox, with the entry's
`email`, for the message that tells them to sign up. When the user signs up,
the entry becomes `joined`.

## Invite codes [#invite-codes]

```ts title="app/admin/codes/actions.ts"
const { code, invite } = await waitlist
  .createCode({
    maxUses: 25,
    expiresAt: Temporal.Now.instant().add({ hours: 24 * 14 }),
    organizationId, // optional: also adds the user to this organization
    role: "member", // optional: defaults to sql.modules.waitlist.options.defaultRole
  })
  .orThrow();
// Show `code` (such as "K7QF-2MXP-9TRD-A4HW") once.

await waitlist.codes(organizationId);
await waitlist.revokeCode(invite.id);
```

Codes are compared case-insensitively. Pass your own with `code` (at least
eight characters), or leave it out for a random one. `maxUses: null` allows
any number of uses. A code can't grant the owner role.

The roles a code may grant are every role but the owner under the `roles`
and `catalog` access models. Under the `provider` and `custom` models list
them in `sql.modules.waitlist.options.roles`; without it codes grant no
role, and a redeemed tenant code adds its user with `defaultRole`. Creating a
code with a role also needs `can_assign(tenant, role)` for callers other than
platform staff and the service role (`WAITLIST_ROLE_FORBIDDEN`), which is
the provider's `canAssign` under the provider model.

The client passes the code in the user metadata at sign-up:

```ts
await supabase.auth.signUp({
  email,
  password,
  options: { data: { invite_code: code } },
});
```

The module counts the use, adds the user to the code's organization, and
removes `invite_code` from the metadata. Change the field with
`sql.modules.waitlist.options.codeField`.

A signed-in user can redeem a code too, such as one that joins an
organization:

```ts
const { organizationId, role } = await waitlist.redeem(code).orThrow();
```

`role` is `undefined` when they were a member already.

## The sign-up hook [#the-sign-up-hook]

`waitlistHook` answers Supabase Auth's before-user-created hook. It verifies
the Standard Webhooks signature, then lets the sign-up through when the
address is approved or the metadata carries a usable code:

```ts title="app/api/auth/before-user-created/route.ts"
import { createPostgres } from "better-supabase/postgres";
import { sqlTransport, waitlistHook } from "better-supabase/blocks/waitlist";

const postgres = createPostgres({ connectionString: env.SUPABASE_DB_URL });

export const POST = waitlistHook({
  transport: sqlTransport(postgres.admin),
  secret: env.BEFORE_USER_CREATED_HOOK_SECRET,
});
```

Register the route under Authentication, Hooks, Before User Created, as an
HTTPS hook. A rejected sign-up gets a 403 with `message` ("Sign-ups are
invite-only. Join the waitlist first." by default). When the database is
unreachable, the hook answers 500 and Supabase Auth rejects the sign-up.

You can call `better_supabase.waitlist_admit(email, code)` from a Postgres
hook instead; it is granted to `supabase_auth_admin`.

## Functions [#functions]

| Function                                                       | Granted to                            | Does                                       |
| -------------------------------------------------------------- | ------------------------------------- | ------------------------------------------ |
| `join_waitlist(email, referrer, metadata)`                     | `anon`, `authenticated`               | Adds an address; returns its place         |
| `list_waitlist(status, page_size, after_position)`             | `authenticated`, `service_role`       | Entries by position, for staff             |
| `decide_waitlist_entry(id, approve)`                           | `authenticated`, `service_role`       | Approves or rejects an entry               |
| `create_invite_code(code, max_uses, expires_at, tenant, role)` | `authenticated`, `service_role`       | Stores a code's hash                       |
| `list_invite_codes(tenant)` and `revoke_invite_code(id)`       | `authenticated`, `service_role`       | Lists or revokes codes                     |
| `waitlist_admit(email, code)`                                  | `service_role`, `supabase_auth_admin` | `{ allowed, reason }` for the sign-up hook |
| `redeem_invite_code(code)`                                     | `authenticated`                       | Uses a code for the caller                 |

# Incoming webhooks

> Trigger URLs a tenant hands to other systems, with hashed tokens, optional signatures, limits and an inbox.

Source: https://bettersupabase.com/docs/blocks/webhooks-in

Automation products let a tenant create a URL that another system calls:
a form, a CRM, a monitoring tool. The `webhooks-in` SQL module keeps those
endpoints per tenant, and `createIncomingWebhooks` from
`better-supabase/blocks/webhooks` serves them. Each delivery is checked, then
stored in the [webhook inbox](/docs/blocks/jobs#webhook-inbox) with the
endpoint's tenant, so it is acknowledged fast and processed with retries.

```bash
better-supabase sql add webhooks-in   # adds access, tenant and webhook-inbox
```

## Endpoints [#endpoints]

A member with `webhooks.manage` (see Permissions below) creates an endpoint for their tenant. The token, which goes in the URL, and
the signing secret are returned once. Only the token's hash is stored, and
the secret is a [Vault](https://supabase.com/docs/guides/database/vault)
secret that only the receiving route decrypts. Set
`sql.modules.webhooks-in.options.secretStorage` to `"column"` to keep it in
the `secret` column instead (members can't read that column either). Deleting
an endpoint, or rotating its secret, deletes the old Vault secret.

```ts
import { createIncomingWebhooks } from "better-supabase/blocks/webhooks";

const hooks = createIncomingWebhooks(ctx.postgres);
const endpoint = await hooks
  .create({
    tenant: organizationId,
    name: "Website form",
    verify: "standard-webhooks",
  })
  .orThrow();
// https://api.example.com/hooks/${endpoint.token}, signed with endpoint.secret

await hooks.rotate(endpoint.id, { rotateSecret: true });
await hooks.setEnabled(endpoint.id, false);
await hooks.remove(endpoint.id);
```

| `verify`            | A delivery must carry                                                                                           |
| ------------------- | --------------------------------------------------------------------------------------------------------------- |
| `none` (default)    | only the token in the URL                                                                                       |
| `standard-webhooks` | a [Standard Webhooks](/docs/standards) signature with the endpoint's `whsec_` secret                            |
| `hmac-sha256`       | the hex HMAC-SHA256 of the body, optionally prefixed `sha256=`, in `signatureHeader` (`x-signature` by default) |
| `shared-secret`     | the endpoint's secret itself in `signatureHeader` (`x-webhook-secret` by default), for senders that cannot sign |

### Rotating the secret [#rotating-the-secret]

`rotate(id)` issues a new token, so the old URL stops working. To replace
only the signing secret and keep the URL, call `rotateSecret(id, { grace })`:

```ts
const { secret, previousSecretExpiresAt } = await hooks
  .rotateSecret(endpoint.id, { grace: "24 hours" })
  .orThrow();
```

During `grace` (a Postgres interval or a `Temporal.Duration`) deliveries
signed with the old secret still verify, so the sender can switch over
without dropped deliveries. Without `grace` the old secret stops at once.
The old Vault secret is deleted at the next rotation, a change of `verify`
or the endpoint's deletion. An endpoint without a secret (`verify: "none"`)
is refused with `WEBHOOK_IN_NO_SECRET`. In SQL it is
`rotate_incoming_webhook_secret(endpoint, grace)`, checked with the `update`
key.

### Changing an endpoint [#changing-an-endpoint]

`update(id, { name?, metadata?, verify?, signatureHeader? })` renames an
endpoint, replaces its `metadata` (to bind it to another workflow, say) or
changes how deliveries are verified, and keeps its token and URL. Fields
you leave out stay as they are.

```ts
const changed = await hooks
  .update(endpoint.id, {
    metadata: { workflowId: nextWorkflowId },
    verify: "hmac-sha256",
  })
  .orThrow();
// changed.secret is the new HMAC secret, shown once
```

Changing `verify` to a signing mode returns a new secret once, and the old
Vault secret is deleted; changing it to `none` drops the secret. The same
`verify` keeps the secret, and `secret` comes back `null`. In SQL it is
`update_incoming_webhook(endpoint, name, metadata, verify, signature_header)`,
which refuses an empty name (`WEBHOOK_IN_NAME_REQUIRED`), metadata that is
not an object (`WEBHOOK_IN_METADATA_INVALID`), an unknown mode
(`WEBHOOK_IN_VERIFY_UNKNOWN`), and a caller without the `update` key
(`WEBHOOK_IN_NOT_FOUND`).

### Permissions [#permissions]

Creating, changing and deleting an endpoint check three keys, each
`webhooks.manage` by default. `permissions.manage` sets all three at once,
and `create`, `update` or `delete` override one of them, so an app can let
members add endpoints while only admins remove them:

```ts title="better-supabase.config.ts"
"webhooks-in": {
  permissions: { manage: "integrations.manage", delete: "integrations.delete" },
},
```

| Action   | Checked by                                                                                                                |
| -------- | ------------------------------------------------------------------------------------------------------------------------- |
| `create` | `create_incoming_webhook`                                                                                                 |
| `update` | `update_incoming_webhook`, `rotate_incoming_webhook`, `rotate_incoming_webhook_secret` and `set_incoming_webhook_enabled` |
| `delete` | `delete_incoming_webhook`                                                                                                 |
| `view`   | the read policy and `list_incoming_webhooks` (`webhooks.read`)                                                            |

Use `shared-secret` only for a sender that can set a fixed header but
cannot compute a signature, such as a form tool or an older CRM. The secret
travels with every request, so it protects against a leaked URL but not
against someone who can read the traffic. `receive` compares it in constant
time and never stores that header, even when `keepHeaders` names it.

Members with `webhooks.read` read their tenant's endpoints, including
`receive_count`, `last_received_at` and `last_status`. `hooks.list(tenant)`
returns them without secrets through `list_incoming_webhooks`, which runs as
the caller.

### Endpoints that belong to a record [#endpoints-that-belong-to-a-record]

An endpoint can belong to a record in your schema, such as the workflow it
starts or the integration it feeds. Map each subject type to its table, the
same way the comments block does:

```ts title="better-supabase.config.ts"
sql: {
  modules: {
    "webhooks-in": {
      options: {
        subjects: {
          workflow: { table: "workflows", cascade: true },
          integration: { table: "app.integrations", permission: "integrations.read" },
        },
      },
    },
  },
},
```

```ts
await hooks.create({
  tenant: organizationId,
  name: "Form submissions",
  subject: { type: "workflow", id: workflowId },
});
const forWorkflow = await hooks.list(organizationId, {
  type: "workflow",
  id: workflowId,
});
```

Creating an endpoint checks that the subject exists in the tenant and that
the caller holds its `permission` (`WEBHOOK_IN_SUBJECT_INVALID` otherwise).
The read policy also requires that the caller can read the subject row
through the subject table's own policies, so an endpoint on a record a
member can't see is hidden from them. `cascade: true` deletes a subject's
endpoints when its row is deleted, and the receiving route stores each
delivery as before, so the inbox handler finds the subject on the endpoint.
`id` defaults to `id` and `tenant` to `organization_id`.

## Receiving [#receiving]

Route the public URL to `receive` on a service connection:

```ts title="app/hooks/[token]/route.ts"
const hooks = createIncomingWebhooks(postgres.admin, {
  rateLimit: { max: 60, period: 60 },
});

export const POST = (
  request: Request,
  { params }: { params: { token: string } },
) => hooks.receive(request, params.token);
```

`receive` answers `404` for an unknown or disabled token, `413` for a body over
the endpoint's `max_body_bytes` (1 MiB by default,
`sql.modules.webhooks-in.options.maxBodyBytes`), `429` with `Retry-After` past
`rateLimit` (counted per endpoint with the
[`rate-limit` module](/docs/blocks/sql#limits-in-route-handlers)), and `401` for a
bad signature. Otherwise it stores the delivery and answers `202`, or `200`
when the same delivery arrived before: one with a `webhook-id` seen before
or, on an `hmac-sha256` endpoint, one with a signature seen before. The id
header isn't signed there, so a replayed body is stored once whatever id it
carries. Every answer after the lookup is counted on the endpoint.

### Tenant exports and purges [#tenant-exports-and-purges]

With the [`data-lifecycle` module](/docs/blocks/data-lifecycle) installed,
a tenant's endpoints are in its organization export, without `token_hash`,
the secrets and their Vault ids, and the purge deletes them together with
their Vault secrets. The endpoint table's columns can't be renamed in
`sql.modules.webhooks-in.columns`.

### Upgrading from version 1 [#upgrading-from-version-1]

Version 2 of the module moves signing secrets to Vault. `better-supabase sql
upgrade` writes a step that adds the `secret_id` column, copies every
plaintext secret into Vault and clears the column; the receiving route reads
either while the step is pending.

## Processing [#processing]

Deliveries are inbox messages of source `webhook-in` (or `source`), with the
endpoint's tenant and its id in the `x-bs-endpoint-id` header:

```ts
import { createWebhookInbox } from "better-supabase/blocks/jobs";
import { INCOMING_ENDPOINT_HEADER } from "better-supabase/blocks/webhooks";

const inbox = createWebhookInbox(postgres.admin, {
  source: "webhook-in",
  verify: () =>
    Promise.reject(
      new Error("deliveries arrive through createIncomingWebhooks"),
    ),
});

await inbox.process(async (message) => {
  const endpointId = message.headers[INCOMING_ENDPOINT_HEADER];
  await startWorkflowFor(message.tenant, endpointId, message.payload);
});
```

A JSON body is stored as it is; any other body as `{ "body": "<text>" }`.

# Outgoing webhooks

> Signed webhooks to your customers' endpoints, with event subscriptions, retries, a queryable delivery log, secret rotation, URL checks and auto-disable.

Source: https://bettersupabase.com/docs/blocks/webhooks-out

The `webhooks-out` [SQL module](/docs/blocks/sql) stores the endpoints
your customers register, their signing secrets and one delivery row per
event and endpoint. `createWebhooks` from
`better-supabase/blocks/webhooks` queues events, signs and sends the deliveries,
retries failures and keeps the log.

```bash
pnpm better-supabase sql add webhooks-out
```

Secrets live in [Supabase Vault](https://supabase.com/docs/guides/database/vault)
by default and clients can never read them. With the
[access contract](/docs/blocks/access) installed, members with
`webhooks.read` read the endpoints and the delivery log of their
organization through RLS, and `webhooks.manage` adds, changes, rotates and
redelivers. Without it, only the service role has access.

## Endpoints [#endpoints]

An endpoint is a row with a `url` (HTTPS unless `options.allowHttp`),
`event_types` and `enabled`. Event types match exactly, with `*` for every
event, or by prefix with `invoice.*`. Create the first secret with
`rotateSecret` and show it to the user once:

```ts title="app/settings/webhooks/actions.ts"
"use server";

import { createWebhooks, sqlTransport } from "better-supabase/blocks/webhooks";

export async function addEndpoint(
  organizationId: string,
  input: { url: string; events: string[] },
) {
  const ctx = await bs.context();
  const db = postgres.asUser(ctx.auth.claims);
  const [endpoint] = await db.queryRaw<{ id: string }>(
    `insert into better_supabase.webhook_endpoints (organization_id, name, url, event_types)
     values ($1, $2, $3, $4) returning id`,
    [organizationId, new URL(input.url).host, input.url, input.events],
  );
  const webhooks = createWebhooks({ transport: sqlTransport(db) });
  return webhooks.rotateSecret(endpoint!.id).orThrow();
}
```

`rotateSecret(id, { overlap })` creates a new secret and keeps the older ones
signing for `overlap` (`'24 hours'` by default), so receivers can switch
without missing a delivery. Pass `secret` to import one, for example when you
migrate from another system.

## Publishing events [#publishing-events]

`publish` queues the event for every enabled endpoint in the organization
that subscribes to its type, and returns how many deliveries it queued.
Publish from a service connection:

```ts title="lib/webhooks.ts"
import "server-only";

import { createWebhooks, sqlTransport } from "better-supabase/blocks/webhooks";

export const webhooks = createWebhooks({
  transport: sqlTransport(postgres.admin),
  events: betterSupabase.events,
});
```

```ts
await webhooks
  .publish({
    type: "invoice.paid",
    data: { id: invoice.id, amount: invoice.amount },
    tenant: organizationId,
    id: `invoice-paid-${invoice.id}`,
  })
  .orThrow();
```

With an `id`, publishing the same event again queues nothing, so a retried
request never sends twice. `dispatch({ endpointId, type, data, runId?,
eventId? })` sends to one endpoint outside its subscriptions, for example
from a workflow step, and returns the delivery id. A member with
`webhooks.manage` may call it too.

To publish from the [outbox](/docs/blocks/outbox), pass `webhooks.sink()` to
the relay. It publishes each CloudEvent with its `partitionkey` as the
organization and its `id` as the event id, and removes the relay's type
prefix so endpoints subscribe to `invoice.paid` rather than
`dev.better-supabase.invoice.paid`. Pass `sink({ typePrefix })` when the relay
uses its own prefix.

## Delivering [#delivering]

Call `deliver()` from a cron route or a job. `deliverRoute` wraps it for
Vercel Cron, behind the `CRON_SECRET` bearer token:

```ts title="app/api/webhooks/deliver/route.ts"
import { webhooks } from "@/lib/webhooks";

export const GET = webhooks.deliverRoute({ secret: process.env.CRON_SECRET });
```

`deliver` claims due rows with `for update skip locked` and leases them
(`lease`, two minutes by default), so two runs never send the same delivery.
The claim counts the attempt, so a worker that dies mid-send still uses one
up, and the attempt is the lease token: a worker whose lease ran out and was
claimed again can't record its result. A batch (`batch`, 25) is sent with
`concurrency` (10) requests in flight, so it finishes inside the lease.
Each request carries the Standard Webhooks headers (`webhook-id`,
`webhook-timestamp` and `webhook-signature`) and the body
`{ type, timestamp, data }`. Receivers check it with
[`verifyWebhook`](/docs/standards/webhooks) or any Standard Webhooks library.
The delivery id is the `webhook-id`, the same on every retry.

| Response                       | Result                            |
| ------------------------------ | --------------------------------- |
| 2xx                            | `succeeded`                       |
| 408, 429, 5xx, a network error | `retrying`, on the schedule below |
| A URL check that fails (DNS)   | `retrying`, on the schedule below |
| Any other status               | `dead`                            |
| A URL `allowUrl` rejects       | `dead`, never sent                |
| The last of `maxAttempts` (8)  | `dead`, also when the worker died |

A delivery is `pending` until a worker claims it and `delivering` while the
lease holds it. `shouldDeliver` returning `false` and disabling the endpoint
leave it `canceled`.

Retries follow the Svix schedule: 5 seconds, 5 minutes, 30 minutes, 2
hours, 5 hours, then 10 hours, each with full jitter (a random wait up to
that long) so failed receivers aren't hit in bursts. A `Retry-After` header
on the response wins when it asks for longer, up to a day.

An endpoint whose deliveries keep failing for `disableAfter` (5 days)
without a success in between is disabled, its pending deliveries are
canceled and the outbox gets `webhook.disabled`. Setting `enabled` back to
`true` clears `failing_since`.
`redeliver(deliveryId)` queues a finished delivery again from its first
attempt.

The log keeps the status, attempt, response status and body (the first
2000 characters), duration and last error of every delivery, so you can show
it in your settings page with a plain query.

## URL checks [#url-checks]

The default `allowUrl` is `publicUrl()`: HTTPS only, no credentials in the
URL, no local host names, and every address the host resolves to must be
public (not loopback, private, link-local or reserved, so cloud metadata
endpoints are out). IPv6 addresses that carry an IPv4 address (mapped,
IPv4-compatible, NAT64, 6to4 and Teredo) are checked by that address or
refused. It resolves with `node:dns` where the runtime has it; pass
`resolve` on other runtimes. Use `allowHosts` for a receiver you trust.

`fetchTransport` follows no redirects by default (`maxRedirects: 0`), so a
redirect dead-letters the delivery; each hop you allow is checked with
`allowUrl` first. It reads at most `maxResponseBytes` (64 KiB) of a response
body. The check and the connection resolve the host separately, so a host
whose DNS changes in between (DNS rebinding) can still reach an internal
address. When that matters, send webhooks through an egress proxy that
refuses private addresses, with a custom `http` transport.

Signing secrets are 32 random bytes (`whsec_` and base64). With Vault
storage, deleting a secret row or its endpoint deletes the Vault secret.

```ts
createWebhooks({
  transport: sqlTransport(postgres.admin),
  allowUrl: publicUrl({ allowHosts: ["hooks.internal.example.com"] }),
});
```

### Other requests to URLs users give you [#other-requests-to-urls-users-give-you]

`createSafeFetch` applies the same checks to any request whose URL comes
from a user or tenant: link previews, imports from a URL, avatars by URL,
OAuth callback checks. It returns a `fetch` that refuses a URL outside
`publicUrl` (or your `allowUrl`) with `UnsafeUrlError`, checks every redirect
before following it (3 at most by default, `maxRedirects`), drops
`authorization`, `cookie` and `proxy-authorization` on a redirect to another
origin, and stops after `timeoutMs` (10 seconds):

```ts
import { createSafeFetch } from "better-supabase/blocks/webhooks";

const safeFetch = createSafeFetch();

const response = await safeFetch(userSuppliedUrl);
```

When your requests carry credentials in other headers, such as an API key
for the service you call, list them in `sensitiveHeaders` so a redirect to
another origin drops them too. Redirects within the same origin keep them.

```ts
const safeFetch = createSafeFetch({ sensitiveHeaders: ["x-api-key"] });
```

When the check itself fails, usually because the DNS lookup for the host
failed, `safeFetch` throws `UrlCheckError` instead, with the lookup error as
`cause`. The URL was not refused, so treat it as a transient failure you may
retry, and treat `UnsafeUrlError` as final:

```ts
import { UnsafeUrlError, UrlCheckError } from "better-supabase/blocks/webhooks";

try {
  await safeFetch(userSuppliedUrl);
} catch (error) {
  if (error instanceof UnsafeUrlError) return { status: "refused" };
  if (error instanceof UrlCheckError) return { status: "retry" };
  throw error;
}
```

The DNS caveat above applies here too.

## Extending it [#extending-it]

| Option or hook                | Use                                                                                     |
| ----------------------------- | --------------------------------------------------------------------------------------- |
| `signer`                      | `standardWebhooks({ headers })` renames the headers; `hmacSigner` matches other formats |
| `transform`                   | The JSON body for a delivery                                                            |
| `headers`                     | Extra headers per delivery, e.g. an `idempotency-key`                                   |
| `shouldDeliver`               | Return `false` (or throw) to cancel a delivery, e.g. for a suspended plan               |
| `retry`                       | `maxAttempts`, `backoff(attempt)` in seconds, `retryable(status)`                       |
| `http`                        | A `WebhookTransport`, e.g. through an egress proxy                                      |
| `secrets`                     | A `WebhookSecretStore`, e.g. a KMS                                                      |
| `after_webhook_delivery` hook | A SQL function that runs after each delivery completes, with its id and status          |
| `webhook.*` block events      | `delivered`, `failed` and `disabled` on `betterSupabase.events`                         |
| `webhooks.*` permissions      | Rename them with `sql.modules.webhooks-out.permissions.manage` and `.view`              |

`testWebhookSigner`, `testWebhookTransport` and `testWebhookSecretStore`
from `better-supabase/testing` check your own implementations against the
contract.

## Existing tables [#existing-tables]

The managed tables are `webhook_endpoints` with `event_types`,
`webhook_endpoint_secrets` and `webhook_deliveries` with `event_type`, keyed
on `organization_id`. Adopt yours, rename what differs and map what you
don't have to `null`. This adopts a `webhook_destinations` table with
`event_kinds`, keeps secrets in a column, stores uuid event and run ids,
maps the stored statuses and signs with an existing HMAC format:

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      "webhooks-out": {
        mode: "adopt",
        idType: "uuid",
        tables: {
          endpoints: "public.webhook_destinations",
          secrets: "public.webhook_destination_secrets",
        },
        columns: {
          endpoints: {
            eventTypes: "event_kinds",
            failingSince: null,
            disabledAt: null,
            disabledReason: null,
          },
          secrets: {
            endpoint: "destination_id",
            vaultId: null,
            expiresAt: null,
          },
          deliveries: {
            endpoint: "destination_id",
            type: "event_kind",
            run: "workflow_run_id",
          },
        },
        options: {
          secretStorage: "column",
          eventIdType: "uuid",
          runIdType: "uuid",
          statuses: {
            delivering: "processing",
            succeeded: "completed",
            retrying: "failed",
            dead: "dead_lettered",
          },
        },
      },
    },
  },
});
```

```ts
createWebhooks({
  transport: sqlTransport(postgres.admin),
  signer: hmacSigner({
    signatureHeader: "x-acme-signature",
    timestampHeader: "x-acme-timestamp",
  }),
});
```

With the default `secretStorage: "vault"`, an adopted secrets table needs
the `vault_secret_id` column (map `columns.secrets.vaultId` when yours has
another name) and no `secret` column: the module never reads it, so `sql
sync` doesn't ask you to map it to `null`. With `secretStorage: "column"` it
is the other way round.

Without `failingSince`, endpoints are never disabled automatically.
Without `expiresAt`, rotating replaces the secret at once instead of
overlapping. With `secretStorage: "column"` the secret is stored as plain
text in a table no client can read. `statuses` maps each status to the value
your table stores; the ones you leave out keep their names. The options
marked migration-only match an existing schema: the config accepts them in
`mode: "adopt"` only, and doctor warns about them (BS314) until you remove them.
The module's functions stay in its own schema (`better_supabase` unless
`schema` names another one that the Data API doesn't expose, see
[BS312](/docs/cli/doctor#bs312)); `tables` points at the adopted tables
wherever they are.

| Option          | Default  | Meaning                                                                      |
| --------------- | -------- | ---------------------------------------------------------------------------- |
| `secretStorage` | `vault`  | `vault`, or `column` (migration-only, adopt mode)                            |
| `allowHttp`     | `false`  | Allow `http:` endpoint URLs                                                  |
| `disableAfter`  | `5 days` | How long an endpoint fails without a success before it's disabled            |
| `eventIdType`   | `text`   | The type of the event id column: `text` or `uuid`; others are migration-only |
| `runIdType`     | `text`   | The type of the run id column: `text` or `uuid`; others are migration-only   |
| `statuses`      | none     | The stored value of each status, in adopt mode only                          |

## Error codes [#error-codes]

| `hint`                         | When                                                         |
| ------------------------------ | ------------------------------------------------------------ |
| `WEBHOOK_TYPE_REQUIRED`        | `publish` or `dispatch` without an event type                |
| `WEBHOOK_FORBIDDEN`            | The caller lacks `webhooks.manage` in the organization       |
| `WEBHOOK_ENDPOINT_NOT_FOUND`   | No endpoint with that id                                     |
| `WEBHOOK_ENDPOINT_DISABLED`    | Dispatching or redelivering to a disabled endpoint           |
| `WEBHOOK_DELIVERY_NOT_FOUND`   | No delivery with that id                                     |
| `WEBHOOK_DELIVERY_IN_PROGRESS` | Redelivering a delivery that hasn't finished                 |
| `WEBHOOK_STATUS_UNKNOWN`       | Completing a delivery with a status the block doesn't know   |
| `WEBHOOK_ATTEMPT_REQUIRED`     | Completing a delivery without the attempt its claim returned |
| `WEBHOOK_SECRET_TOO_SHORT`     | Importing a secret shorter than 16 characters                |

# Workflow builder

> Graph workflows that members edit and publish per tenant, with webhook, schedule and event triggers, credentials by reference, node-level run status and alerts on failed or slow runs.

Source: https://bettersupabase.com/docs/blocks/workflow-builder

The `workflow-builder` block stores workflows as graphs that members edit on
a canvas and publish per tenant. A published version runs on the engine you
choose. The [Workflow SDK](/docs/blocks/workflow-sdk#graph-workflows)
adapter compiles each version to a workflow and records the status of each
node, so the canvas shows a run as it happens.

```bash
better-supabase sql add workflow-builder   # adds workflows, tenant and access as well
```

| Table                     | Holds                                                                                                            |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `workflow_definitions`    | One row per workflow: `tenant_id`, `slug`, `name`, `description`                                                 |
| `workflow_versions`       | Numbered versions of a definition: `graph`, `compiled`, `status` (`draft`, `published`, `archived`)              |
| `workflow_triggers`       | What starts a definition: `kind` (`manual`, `webhook`, `schedule`, `event`, `form`, `chat`), `config`, `enabled` |
| `workflow_webhook_tokens` | The SHA-256 of each webhook trigger's token; only the service role reads it                                      |
| `workflow_credentials`    | A tenant's credentials by `kind` and `name`, as a `credential_ref`; never the secret itself                      |
| `workflow_step_library`   | The steps your deployment provides, which the canvas offers as its palette                                       |
| `workflow_node_runs`      | The status, attempts, output and error of each node of a run                                                     |
| `workflow_alerts`         | Alerts on a definition's failed runs, or runs that stay unfinished past a threshold                              |
| `workflow_alert_fires`    | Which alert fired for which run, so each fires once; only the service role reads it                              |

| Permission         | Lets                                                          | Default roles   |
| ------------------ | ------------------------------------------------------------- | --------------- |
| `workflow.read`    | members read the tenant's definitions, versions and node runs | none by default |
| `workflow.run`     | members start a published version                             | none by default |
| `workflow.edit`    | members create definitions and save drafts                    | none by default |
| `workflow.publish` | members publish a version                                     | none by default |
| `workflow.admin`   | members manage triggers, credentials and alerts               | none by default |

Grant the keys to your roles in `sql.modules.access.roles`.

## Graphs [#graphs]

A graph is a list of nodes and the edges between them. It has one
`trigger` node, and the other nodes run in dependency order once the trigger
reaches them.

```ts
import type { WorkflowGraph } from "better-supabase/blocks/workflow-builder";

const graph: WorkflowGraph = {
  nodes: [
    { id: "start", kind: "trigger" },
    {
      id: "lookup",
      kind: "step",
      step: "crm.lookup",
      config: { field: "email" },
    },
    {
      id: "isPaid",
      kind: "condition",
      config: { path: "results.lookup.plan", op: "equals", value: "pro" },
    },
    { id: "review", kind: "approval" },
    { id: "wait", kind: "sleep", config: { duration: "1d" } },
    {
      id: "welcome",
      kind: "step",
      step: "email.send",
      config: { template: "welcome" },
    },
  ],
  edges: [
    { id: "e1", source: "start", target: "lookup" },
    { id: "e2", source: "lookup", target: "isPaid" },
    { id: "e3", source: "isPaid", target: "review", branch: "true" },
    { id: "e4", source: "isPaid", target: "wait", branch: "false" },
    { id: "e5", source: "review", target: "welcome", branch: "true" },
    { id: "e6", source: "wait", target: "welcome" },
  ],
};
```

| Kind        | Does                                                                                                                                     |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `trigger`   | Passes the run's input on                                                                                                                |
| `step`      | Calls the library step `step` with its `config`, the input and the outputs of the nodes before it                                        |
| `sleep`     | Waits `config.duration`: milliseconds, or `30s`, `5m`, `2h`, `1d`                                                                        |
| `approval`  | Waits for its hook to be resumed; the payload `{ approved: false }` takes the `false` branch                                             |
| `condition` | Tests `config.path` (under `input` or `results`) with `equals`, `notEquals`, `exists`, `truthy`, `greaterThan`, `lessThan` or `contains` |

An edge without a `branch` runs its target when its source ran. From a
condition or an approval, `branch: "true"` or `"false"` picks the outcome
it follows. A node runs when one of its incoming edges is active, so two
branches can join again.

`validateGraph(graph, steps)` returns the errors: a missing or second
trigger, a cycle, an edge to an unknown node, a step outside the library or
an invalid condition. The database runs the same checks when it saves a
draft and when it publishes one. `diffGraphs(from, to)` lists the nodes and
edges that were added, removed or changed.

## Editing and publishing [#editing-and-publishing]

```ts title="lib/workflow-builder.ts"
import "server-only";

import {
  createBuilder,
  sqlTransport,
} from "better-supabase/blocks/workflow-builder";
import {
  compileGraph,
  graphStarter,
} from "better-supabase/workflow-sdk/builder";

import { steps, stepLibrary } from "@/workflows/steps";
import { graphExecutor } from "@/workflows/graph";

export const builderFor = (sql: Sql) =>
  createBuilder({
    transport: sqlTransport(sql),
    service: sqlTransport(postgres.admin),
    steps: stepLibrary,
    compile: compileGraph,
    start: graphStarter({ steps, executor: graphExecutor }),
  });
```

```ts
const builder = builderFor(await bs.sql());

const definition = await builder.definitions
  .save({ tenant: organizationId, slug: "onboarding", name: "Onboarding" })
  .orThrow();
const draft = await builder.versions.save(definition.id, graph).orThrow();
const published = await builder.versions.publish(draft.id).orThrow();
const runId = await builder
  .run({ definition: definition.id, input: { email } })
  .orThrow();
```

`versions.save` updates the open draft, or opens the next version when the
last one is published. `versions.publish` runs `compile` on the graph
(a throw fails the publish), publishes the version as the caller and
archives the version that was published before. The compiled form is
stored through the `service` transport; without one it isn't stored.
`publish_workflow_version` refuses a non-null `compiled` from anyone but
the service role (`WORKFLOW_COMPILED_FORBIDDEN`), so a member with
`workflow.publish` can't store code for the server to run. `run` starts the published version through
`start` with a fresh idempotency key unless you pass one, and returns the
engine's run id. The run appears in `workflow_runs` like any other run.

`steps.sync()` writes `steps` to the library (service role). Run it at
deploy time or on start-up, so the palette and the database's checks know
every step the deployment has. Each step has a `name`, a `title`, JSON
Schemas for its config and output, and the `credentialKind` it needs.

## Triggers [#triggers]

```ts
await builder.triggers.save({ definition: id, kind: "webhook" }).orThrow();
const token = await builder.triggers.rotateToken(triggerId).orThrow(); // shown once
```

| Kind       | How it starts a run                                                                                                    |
| ---------- | ---------------------------------------------------------------------------------------------------------------------- |
| `manual`   | `builder.run()` from your UI                                                                                           |
| `webhook`  | `builder.triggers.webhook(request)` in a route: the token in the path or a `Bearer` header, the JSON body as the input |
| `schedule` | `triggers.syncSchedule(trigger)` creates a [workflows schedule](/docs/blocks/workflows#schedules) from `config.cron`   |
| `event`    | `builder.triggers.onEvent(event)` from your outbox consumer starts every definition listening for `config.type`        |
| `form`     | Your form calls `run()`; `config.schema` holds its fields                                                              |
| `chat`     | Your chat calls `run()`                                                                                                |

```ts title="app/api/hooks/workflows/[token]/route.ts"
export const POST = (request: Request) =>
  serviceBuilder.triggers.webhook(request);
```

The webhook route answers `202` with `{ runId }`, `404` for an unknown or
disabled token and `405` for a method other than `POST`. A body that is not
JSON arrives as `{ body: "<text>" }`. An `Idempotency-Key`
header keys the start, so a retried delivery starts one run. Only the
token's SHA-256 is stored; rotating it ends the old one.

Schedule triggers run on the `workflows` schedule tick. Pass
`builder.triggers.starter(fallback)` as its `start`: it starts the
`builder-trigger:` schedules here and hands the others to `fallback`.

## Credentials [#credentials]

```ts
import { vaultCredentials } from "better-supabase/credentials";

const builder = createBuilder({
  transport,
  service,
  credentials: vaultCredentials(sql),
});

const slack = await builder.credentials
  .create({
    tenant: organizationId,
    kind: "slack",
    name: "Team Slack",
    ref,
    secret,
  })
  .orThrow();
const { token } = await builder.credentials.resolve(slack.id).orThrow(); // inside a step
await builder.credentials.revoke(slack.id);
```

A credential row holds a `credential_ref`, never the secret, and the ref
carries the row's tenant ([tenant refs](/docs/extending/credentials#tenant-refs)). `create` stores
`secret` first when the provider can store one, and `authorize` returns the
URL that connects an account for providers that authorize (such as
[Vercel Connect](/docs/extending/credentials)). `resolve` is a service-role
call for steps: it returns the token from the provider named in the ref,
with the row's scopes. `revoke` deletes the row and then revokes the
secret. Members with `workflow.admin` manage the tenant's credentials;
members with `workflow.read` list them. Organization exports leave out
`credential_ref`, and the organization purge in [data lifecycle](/docs/blocks/data-lifecycle#credentials) revokes each
credential before it deletes the row.

## Run status on the canvas [#run-status-on-the-canvas]

`nodeRunReporter` from `better-supabase/workflow-sdk/builder` wraps each
step so it records `running`, then `completed` with its output or `failed`
with its error, in `workflow_node_runs`. Each record pings
`workflow-run:<id>`.

```tsx title="components/workflow-canvas.tsx"
"use client";

import {
  useWorkflowBuilder,
  useWorkflowCanvasRun,
} from "better-supabase/blocks/workflow-builder/react";

export function CanvasStatus({ runId }: { runId: string }) {
  const { run, nodes } = useWorkflowCanvasRun(runId);
  return (
    <p>
      {run?.status}:{" "}
      {Object.values(nodes).filter((n) => n.status === "completed").length}{" "}
      nodes done
    </p>
  );
}
```

`useWorkflowBuilder({ tenant })` returns the definitions, the step library
and a `builder` bound to the user's session. `useWorkflowCanvasRun(id)`
returns the run and each node's status by node id, and loads them again on
every message on `workflow-run:<id>`. Both are Client Component hooks; in
Server Components, call `builder.definitions.list()` and
`builder.nodeRuns.list(run)` instead.

## Alerts [#alerts]

```ts
await builder.alerts.save({
  definition: id,
  onEvent: "failed",
  channel: { type: "email", to },
});
await builder.alerts.save({
  definition: id,
  onEvent: "slow",
  threshold: 3600,
  channel,
});
await serviceBuilder.alerts.check(); // from a cron route
```

A `failed` alert fires when a run of the definition fails. A `slow` alert
fires from `alerts.check()` when a run is still unfinished `threshold`
seconds after it started. Each alert fires once per run and emits a
`workflow_alert.triggered` outbox event with the alert, its `channel` and the run.
Your outbox consumer sends it; the module only stores the channel. Runs
match a definition on their `bs.definition` attribute, which
`graphStarter` sets.

## Functions [#functions]

| Function                                                                                                         | Granted to                      | Does                                             |
| ---------------------------------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------ |
| `save_workflow_definition`, `workflow_definitions_list`, `workflow_definition_get`, `remove_workflow_definition` | `authenticated`, `service_role` | Definitions                                      |
| `save_workflow_draft(definition, graph)`, `publish_workflow_version(version, compiled)`                          | `authenticated`, `service_role` | Drafts and publishing                            |
| `workflow_versions_list`, `workflow_version_get`, `validate_workflow_graph`                                      | `authenticated`, `service_role` | Versions and graph checks                        |
| `workflow_start_target(definition)`                                                                              | `authenticated`, `service_role` | The published version a `workflow.run` may start |
| `save_workflow_trigger`, `workflow_triggers_list`, `remove_workflow_trigger`, `rotate_workflow_webhook_token`    | `authenticated`, `service_role` | Triggers                                         |
| `workflow_webhook_target(token)`, `workflow_event_targets(type, tenant)`                                         | `service_role`                  | What a webhook or an event starts                |
| `save_workflow_credential`, `workflow_credentials_list`, `remove_workflow_credential`                            | `authenticated`, `service_role` | Credentials                                      |
| `workflow_credential_get(id)`                                                                                    | `service_role`                  | A credential's ref, for `resolve`                |
| `sync_workflow_steps(steps)`, `workflow_steps_list()`                                                            | `service_role`, `authenticated` | The step library                                 |
| `record_workflow_node_run(...)`, `workflow_node_runs_list(run)`                                                  | `service_role`, `authenticated` | Node status                                      |
| `save_workflow_alert`, `workflow_alerts_list`, `remove_workflow_alert`                                           | `authenticated`, `service_role` | Alerts                                           |
| `check_workflow_alerts(batch)`                                                                                   | `service_role`                  | Fires the slow alerts that are due               |

# Workflow SDK

> Run the Workflow SDK on Supabase with a World over Postgres and Supabase Queues, and start runs, resume hooks and protect routes as the signed-in user.

Source: https://bettersupabase.com/docs/blocks/workflow-sdk

The Workflow SDK (`workflow`) needs a World: the storage and queue its runs,
steps, hooks and events live in. `better-supabase/workflow-sdk/world` is a
World on your Supabase database. Its tables sit in a `workflow` schema that
only the service role reads, its messages wait in a
[jobs](/docs/blocks/jobs) queue, and every run is copied into the
[workflows](/docs/blocks/workflows) block's `workflow_runs`, so members
list and follow runs through RLS. `better-supabase/workflow-sdk` has the
helpers that start runs and resume hooks as the signed-in user.

```bash
better-supabase sql add workflow-sdk-world   # adds workflows and jobs as well
pnpm add workflow @workflow/world @workflow/world-postgres pg
```

The world entry imports `pg` and Node built-ins, so it runs on Node, not on
edge runtimes. The helpers in `better-supabase/workflow-sdk` import only
`workflow`.

## Selecting the World [#selecting-the-world]

```ts title="next.config.ts"
import { withWorkflow } from "workflow/next";

export default withWorkflow(nextConfig);
```

```bash title=".env"
WORKFLOW_TARGET_WORLD=better-supabase/workflow-sdk/world
SUPABASE_DB_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
```

The entry's default export is `createWorld()`, which reads its options from
the environment, so the SDK and tools that select a World by package name
load it without code. To build one yourself, call `createSupabaseWorld`:

```ts title="lib/world.ts"
import { createSupabaseWorld } from "better-supabase/workflow-sdk/world";

export const world = createSupabaseWorld({
  connectionString: process.env.SUPABASE_DB_URL,
  delivery: "pg_net",
});
```

| Variable                                                   | Option             | Default                                                                                     |
| ---------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------- |
| `WORKFLOW_POSTGRES_URL`, `SUPABASE_DB_URL`, `DATABASE_URL` | `connectionString` | the first one set                                                                           |
| `WORKFLOW_DELIVERY`                                        | `delivery`         | `poll`                                                                                      |
| `WORKFLOW_FLOW_URL`                                        | `flowUrl`          | `WORKFLOW_LOCAL_BASE_URL` or `http://localhost:$PORT`, plus `/.well-known/workflow/v1/flow` |
| `WORKFLOW_DELIVERY_SECRET`                                 | `deliverySecret`   | the Vault secret `workflow_delivery_secret`                                                 |
| `WORKFLOW_ENCRYPTION_KEY`                                  | `encryptionKey`    | the Vault secret `workflow_encryption_key`                                                  |
| `WORKFLOW_DELIVERY_QUEUE`                                  | `queue`            | `workflow_deliveries`                                                                       |
| `WORKFLOW_POSTGRES_WORKER_CONCURRENCY`                     | `concurrency`      | 50                                                                                          |
| `WORKFLOW_POSTGRES_POLL_INTERVAL_MS`                       | `pollInterval`     | 250                                                                                         |

The World speaks the `@workflow/world` protocol in
`WORKFLOW_WORLD_PROTOCOL` and stores its tables in the shape of the
`@workflow/world-postgres` version in `WORLD_POSTGRES_VERSION`. It passes
the `@workflow/world-testing` suite.

## Delivery [#delivery]

The SDK hands each step and each workflow turn to the queue, and the queue
posts it to the flow route that `withWorkflow` adds.

| Mode             | Who posts                                                                | Use it on                                     |
| ---------------- | ------------------------------------------------------------------------ | --------------------------------------------- |
| `poll` (default) | the World, in the process that queues: it claims jobs and posts them     | a server, a container, `next dev`             |
| `pg_net`         | `dispatch_workflow_deliveries()` on a pg\_cron schedule, through pg\_net | serverless hosts with no long-running process |

For `pg_net`, set `sql.modules.workflow-sdk-world.options.delivery` to
`pg_net` (the data file then schedules the dispatcher every
`options.schedule`, `1 seconds` by default) and store the route and a
secret in Vault:

```sql
select vault.create_secret('https://app.example.com/.well-known/workflow/v1/flow', 'workflow_flow_url');
select vault.create_secret(encode(extensions.gen_random_bytes(32), 'hex'), 'workflow_delivery_secret');
```

In poll mode, the first message a process queues starts its poller, and
`world.start()` also queues the runs that were active when the last
process stopped. Call it once at startup, for example from
`instrumentation.ts`.

Each delivery is signed with the secret (`x-bs-signature`, an HMAC-SHA256
over the time, the job and the body). The World's queue handler refuses an
unsigned or stale request with a 401 Problem Details response, and
completes or fails the job itself, so a delivery whose response is lost runs
again after its lease. Leave the flow route out of your proxy's matcher: the
World checks the signature, not a session.

Poll mode needs the secret too, unless `NODE_ENV` is `development` or
`test`: without one, the flow route answers every delivery with 401. Store
it in Vault as above or set `WORKFLOW_DELIVERY_SECRET`. A secret or key that
isn't in Vault counts as not set; a Vault read that fails is not cached, so
the next delivery reads again, and until then the route answers 503 and run
data can't be read or written.

A poll delivery that the flow route doesn't answer within `deliveryTimeout`
seconds (300 by default, at least `lease`) is aborted and retried. The
poller extends the job's lease only while the request is open.

## Encryption [#encryption]

With a 32-byte master key (base64 or hex) in `encryptionKey` or the Vault
secret `workflow_encryption_key`, the World derives an AES-256 key per run
with HKDF-SHA256, and the SDK stores the run's inputs, outputs and step
data encrypted. Without one, they are stored as JSON in the `workflow`
schema. When Vault can't be read, the World doesn't fall back to
unencrypted data: the read fails, and the next one tries Vault again.

## Starting runs as the user [#starting-runs-as-the-user]

```ts title="app/invoices/actions.ts"
"use server";

import { startFor } from "better-supabase/workflow-sdk";

import { approveInvoice } from "@/workflows/approve-invoice";

export async function requestApproval(invoiceId: string) {
  const ctx = await server.context();
  const run = await startFor(ctx, approveInvoice, [invoiceId], {
    idempotencyKey: `approve:${invoiceId}`,
  });
  return run.runId;
}
```

`startFor` starts the run with the `bs.tenant` and `bs.actor` attributes
of the caller, which the World copies into `workflow_runs.tenant_id` and
`actor_id`. With `idempotencyKey`, it returns the run that already has the
key instead of starting another. To act as the user inside a step, pass
`workflowContext(ctx)` as an argument: it keeps the actor and tenant and
drops the claims, so no token ends up in the run's stored state.

| Helper                                     | Does                                                                                                   |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------ |
| `workflowStarter({ name: workflow })`      | The `start` callback for `schedules.tick` and `admission.tick`, as the schedule's or request's creator |
| `startOnEvent(workflow, { types })`        | An outbox handler that starts the workflow per matching event (`invoice.paid`, `invoice.*`, `*`), once |
| `hookMetadata(ctx, permission?)`           | Metadata for `createHook`, naming the actor and the permission a tenant member needs to resume it      |
| `authorizeHook(token, ctx, { can })`       | The hook, when the caller created it or holds its permission; `HookForbiddenError` (403) otherwise     |
| `protectWebHandler(handler, key, { can })` | Answers 401 or 403 as `application/problem+json` before a workflow UI route or a run's stream          |

## Approvals with hooks [#approvals-with-hooks]

```ts title="workflows/approve-invoice.ts"
import type { RequestContext } from "better-supabase";

import { hookMetadata } from "better-supabase/workflow-sdk";
import { createHook } from "workflow";

export async function approveInvoice(invoiceId: string, ctx: RequestContext) {
  "use workflow";
  const hook = createHook<{ approved: boolean }>({
    metadata: hookMetadata(ctx, "invoice.approve"),
  });
  const { approved } = await hook;
  // ...
}
```

```ts title="app/api/approvals/[token]/route.ts"
import { authorizeHook } from "better-supabase/workflow-sdk";
import { resumeHook } from "workflow/api";

export async function POST(request: Request, { params }) {
  const { token } = await params;
  const ctx = await server.context(request);
  await authorizeHook(token, ctx, {
    can: (tenant, permission) => canInTenant(ctx, tenant, permission),
  });
  await resumeHook(token, await request.json());
  return new Response(null, { status: 204 });
}
```

A hook token in a link or an email is not enough to resume the run:
`authorizeHook` also checks who is asking. `can` is your permission check,
for example a call to `member_can(auth.uid(), tenant, permission)`.

## Graph workflows [#graph-workflows]

`better-supabase/workflow-sdk/builder` runs the graphs of the
[workflow builder](/docs/blocks/workflow-builder) on the Workflow SDK.
`compileGraph` turns a graph into dynamic workflow source: each step node
calls its own step, sleep nodes call `sleep` and approval nodes wait on
`createHook({ token: "<runKey>:<node>" })`. Pass it as `compile`, and
`graphStarter` as `start`, to `createBuilder`.

```ts title="workflows/graph.ts"
import {
  executeGraph,
  type GraphStepCall,
  type WorkflowGraph,
} from "better-supabase/blocks/workflow-builder";
import { createHook, sleep } from "workflow";

import { steps } from "./steps";

export async function graphExecutor(
  graph: WorkflowGraph,
  input: unknown,
  meta: { runKey: string },
) {
  "use workflow";
  return executeGraph(graph, input, {
    runKey: meta.runKey,
    step: (call: GraphStepCall) => steps[call.step](call),
    sleep: (ms) => sleep(ms),
    approval: (token) => createHook({ token }),
  });
}
```

The executor imports `executeGraph` from the block rather than from
`workflow-sdk/builder`, so the workflow bundle doesn't load `workflow/api`,
which the workflow sandbox can't run.

```ts title="workflows/steps.ts"
import {
  nodeRunReporter,
  type GraphStepCall,
} from "better-supabase/workflow-sdk/builder";

const report = nodeRunReporter(serviceBuilder.nodeRuns);

export async function sendEmail(call: GraphStepCall) {
  "use step";
  return report(call, () => email.send(call.config));
}

export const steps = { "email.send": sendEmail };
```

Dynamic workflows are experimental in the Workflow SDK. With
`WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1`, `graphStarter` compiles the
published version's graph again on every start and runs that source; it
never runs the `compiled` form stored with the version. Source over 128 KiB
fails to publish. Node ids are 1 to 100 letters, digits, underscores or
hyphens, in `validateGraph` and in `validate_workflow_graph`. Without it, `graphStarter` starts `executor`, a static
`"use workflow"` function that walks the stored graph with `executeGraph`
and takes the same branches. Either way a run carries `bs.definition`,
`bs.version`, `bs.tenant`, `bs.actor` and `bs.key`, and a start with a key
that already ran returns the first run.

`nodeRunReporter` records each step node's status, output and error in
`workflow_node_runs` for the canvas. A record that fails never fails the
step.

## Analytics [#analytics]

`world.analytics` lists runs (by workflow name, status, attributes and a
time window), their steps, events, hooks and waits, and the attribute keys
in use. Pages hold at most 100 runs or 1000 steps and events. `startFor`
uses it to find a run by its idempotency key.

# Workflows

> A run registry for any workflow engine that members read through RLS, cron schedules, counting semaphores, admission control for starts, and useWorkflowRuns over Realtime.

Source: https://bettersupabase.com/docs/blocks/workflows

The `workflows` block records the runs of your durable workflows in one
table, whatever engine runs them, so a run list, a run page and a cancel
button work the same way for every engine. It also adds schedules,
semaphores and admission control. Each of these hands a start to the engine
you choose rather than running the workflow itself. The
[Workflow SDK adapter](/docs/blocks/workflow-sdk) is the engine that ships
with it.

```bash
better-supabase sql add workflows   # adds tenant and access as well
```

| Table                     | Holds                                                                                                                          |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `workflow_runs`           | One row per run: `engine`, `external_id`, `definition`, `tenant_id`, `actor_id`, `status`, `attributes`, `error` and the times |
| `workflow_schedules`      | Recurring starts: `name`, `workflow`, `input`, `cron`, `timezone`, `next_run_at`, `paused`                                     |
| `workflow_semaphores`     | The holders of each semaphore key, until they release it or their lease ends                                                   |
| `workflow_start_requests` | Starts waiting for room under their key's concurrency, debounce or singleton rule                                              |

| Permission       | Lets                                                        | Default roles   |
| ---------------- | ----------------------------------------------------------- | --------------- |
| `workflow.read`  | members read the tenant's runs and schedules                | none by default |
| `workflow.run`   | members start runs, for helpers such as `protectWebHandler` | none by default |
| `workflow.admin` | members cancel any run of the tenant and manage schedules   | none by default |

Grant the keys to your roles in `sql.modules.access.roles`. A user always
reads the runs they started, without a permission.

A run's status is `queued`, `running`, `waiting`, `completed`, `failed` or
`cancelled`. Every status change sends a payload-free Realtime message on
`workflow-run:<id>` and, for a run with a tenant, on
`workflow-runs:<tenant>`. With the [outbox](/docs/blocks/outbox) installed,
a run that ends emits `workflow_run.completed`, `workflow_run.failed` or
`workflow_run.cancelled`.

## Reading runs [#reading-runs]

```ts title="app/workflows/page.tsx"
import {
  createWorkflows,
  rpcTransport,
} from "better-supabase/blocks/workflows";

const workflows = createWorkflows({ transport: rpcTransport(supabase) });

const runs = await workflows.runs
  .list({ tenant: organizationId, status: "running", limit: 50 })
  .orThrow();
const run = await workflows.runs.get(runId).orThrow();
await workflows.runs.requestCancel(runId);
```

`runs.get` and `requestCancel` take the row's id or the engine's run id.
`list` returns the newest runs first; pass the last run's
`createdAt` as `cursor` for the next page. `requestCancel` sets `cancel_requested_at`
for the run's actor, a `workflow.admin` of its tenant or the service role.
The engine does the cancelling. The Workflow SDK adapter calls
`run.cancel()` for you.

Engines write runs with `runs.record` (service role), and
`runs.purge({ olderThan })` deletes finished runs older than 30 days by
default.

## In Client Components [#in-client-components]

```tsx title="components/run-list.tsx"
"use client";

import { useWorkflowRuns } from "better-supabase/blocks/workflows/react";

export function RunList({ organizationId }: { organizationId: string }) {
  const { runs, error } = useWorkflowRuns({ tenant: organizationId });
  if (error) return <p role="alert">{error.message}</p>;
  return (
    <ul>
      {runs?.map((run) => (
        <li key={run.id}>
          {run.definition}: {run.status}
        </li>
      ))}
    </ul>
  );
}
```

`useWorkflowRuns` loads the list and loads it again on every message on
`workflow-runs:<tenant>`. Without a `tenant`, it loads the user's own runs
once. `useWorkflowRun(id)` reads one run, follows `workflow-run:<id>` and
returns `cancel()`. In Server Components, call `workflows.runs.list()`
instead.

## Schedules [#schedules]

Workflow schedules sit between job schedules and per-user AI tasks; see
[three scheduling layers](/docs/blocks/jobs#three-scheduling-layers).

```ts title="app/api/cron/workflows/route.ts"
import {
  createWorkflows,
  sqlTransport,
} from "better-supabase/blocks/workflows";
import { workflowStarter } from "better-supabase/workflow-sdk";

import { weeklyDigest } from "@/workflows/digest";

const workflows = createWorkflows({ transport: sqlTransport(postgres.admin) });

await workflows.schedules.create({
  name: "weekly-digest",
  workflow: "weeklyDigest",
  cron: "0 9 * * 1",
  timezone: "Europe/Amsterdam",
  input: [organizationId],
  tenant: organizationId,
});

export async function GET() {
  const result = await workflows.schedules
    .tick({ start: workflowStarter({ weeklyDigest }) })
    .orThrow();
  return Response.json(result);
}
```

`cron` takes five cron fields, a macro such as `@daily` or an interval such
as `30 seconds`. `tick` claims the due schedules, starts each one with the
idempotency key `schedule:<id>:<fire time>` and moves it to its next time,
so a tick that runs twice for the same fire time starts one run. A start
that throws stays due and runs again once its lease ends. Call `tick` from
a cron route or a job every minute.

Members with `workflow.admin` create, pause and remove the schedules of
their tenant; the service role manages every schedule.

## Semaphores and admission [#semaphores-and-admission]

```ts
const free = await workflows.semaphores
  .acquire("stripe-sync", runId, { max: 3, ttl: 300 })
  .orThrow();
await workflows.semaphores.release("stripe-sync", runId);

await workflows.admission.request({
  key: `import:${organizationId}`,
  workflow: "importContacts",
  input: [fileId],
  tenant: organizationId,
  concurrency: 1,
  debounce: 30,
});
await workflows.admission.tick({ start: workflowStarter({ importContacts }) });
```

A semaphore lets at most `max` holders take a key at once, each until it
releases the key or its `ttl` runs out. Admission queues starts by key:
`concurrency` caps the runs of the key that are active, `debounce` waits
that many seconds and replaces a request that is still waiting, and
`singleton` drops the request while a run with the key is waiting or
active. `admission.tick` starts the requests whose key has room, with the
idempotency key `admission:<id>`. Both are service-role functions.

## Functions [#functions]

| Function                                                            | Granted to                      | Does                                               |
| ------------------------------------------------------------------- | ------------------------------- | -------------------------------------------------- |
| `workflow_runs_list(tenant, definition, status, max, before)`       | `authenticated`, `service_role` | The runs the caller may read, newest first         |
| `workflow_run_get(run)`                                             | `authenticated`, `service_role` | One run by its id or its engine's id               |
| `request_workflow_cancel(run)`                                      | `authenticated`, `service_role` | Marks a run for cancellation                       |
| `record_workflow_run(...)`                                          | `service_role`                  | Creates or updates a run by engine and external id |
| `purge_workflow_runs(older_than, batch)`                            | `service_role`                  | Deletes finished runs                              |
| `create_workflow_schedule(...)`                                     | `authenticated`, `service_role` | Creates or replaces a schedule by tenant and name  |
| `workflow_schedules_list(tenant)`                                   | `authenticated`, `service_role` | The schedules the caller may read                  |
| `pause_workflow_schedule(id, paused)`, `remove_workflow_schedule`   | `authenticated`, `service_role` | Pauses, resumes or removes a schedule              |
| `claim_due_workflow_schedules`, `advance_workflow_schedule`         | `service_role`                  | The schedule tick                                  |
| `acquire_workflow_semaphore`, `release_workflow_semaphore`          | `service_role`                  | Semaphores                                         |
| `request_workflow_start`, `claim_workflow_start_requests`, `mark_*` | `service_role`                  | Admission control                                  |

# Build a ChatGPT clone

> The modules, routes and components behind the example's /assistant and /knowledge pages, from a stored chat to tenant keys and metering.

Source: https://bettersupabase.com/docs/build/chatgpt-clone

The Next.js example's `/assistant` page is a chat app on the
[AI chat block](/docs/blocks/ai-chat) and the [AI SDK](/docs/ai-sdk): a chat
list, branching messages, resumable streams with stop, file uploads, a
knowledge search tool and memory. This guide walks through it in the order
you would build it. Run it with `pnpm dev:portless` and sign in as
`member@acme.test` (password `password123`); without `AI_GATEWAY_API_KEY`
a scripted model answers, so it runs offline.

## The modules [#the-modules]

```ts title="better-supabase.config.ts"
sql: {
  modules: {
    streams: { api: "api" },
    "ai-chat": { api: "api" },
    "ai-files": { api: "api" },
    knowledge: { api: "api" },
    memory: { api: "api" },
    agents: { api: "api" },
    connectors: { api: "api" },
    "ai-tasks": { api: "api" },
    "ai-providers": { api: "api" },
    "ai-cache": { api: "api" },
  },
},
```

| Module         | Gives the clone                                                       | Page                                      |
| -------------- | --------------------------------------------------------------------- | ----------------------------------------- |
| `ai-chat`      | chats, folders, pins, the message tree, runs, sharing and the catalog | [AI chat](/docs/blocks/ai-chat)           |
| `streams`      | resumable answers that survive a reload                               | [Streams](/docs/blocks/streams)           |
| `ai-files`     | uploads read as the caller, and generated files                       | [AI files](/docs/blocks/ai-files)         |
| `knowledge`    | documents chunked and embedded for the search tool                    | [Knowledge](/docs/blocks/knowledge)       |
| `memory`       | facts and core memory per user                                        | [Memory](/docs/blocks/memory)             |
| `agents`       | custom assistants with their own instructions and tools               | [Agents](/docs/blocks/agents)             |
| `connectors`   | MCP servers with per-user OAuth                                       | [Connectors](/docs/blocks/connectors)     |
| `ai-tasks`     | prompts on a schedule                                                 | [AI tasks](/docs/blocks/ai-tasks)         |
| `ai-providers` | tenant keys, batches and sandboxes                                    | [AI providers](/docs/blocks/ai-providers) |
| `ai-cache`     | repeated model calls answered from Postgres                           | [AI cache](/docs/blocks/ai-cache)         |

Run `better-supabase sql sync`, generate a migration with
`supabase db schema declarative sync`, then `better-supabase sql data`.

### The chat route [#the-chat-route]

`createAssistant` from `better-supabase/ai-sdk/chat` is the whole server
side: it stores the user's message, runs the model, streams the answer
through the stream store and saves it when it ends. The example builds one
per process in `src/features/assistant/assistant-server.ts` and mounts it
on three routes:

| Route                       | Does                                      |
| --------------------------- | ----------------------------------------- |
| `POST /api/chat`            | stores the message and streams the answer |
| `GET /api/chat/[id]/stream` | resumes the answer after a reload         |
| `POST /api/chat/[id]/stop`  | stops the answer                          |

`assistantContext` builds the per-request context: the caller's chat block,
their files, the knowledge search tool and their memory, all read through
RLS as the signed-in member.

### The client [#the-client]

`useAssistant` from `better-supabase/ai-sdk/react` is `useChat` wired to
those routes. The chat list, the new-chat form and the
stored chat live in `src/features/assistant/components`. Edit and
regenerate create sibling messages; `messages.siblings` and
`messages.switchBranch` move between them.

### Files and knowledge [#files-and-knowledge]

Uploads go to Storage as the caller, and `aiFileDownload` reads them back for
the model, so a member never reads another member's file. `/knowledge`
ingests documents into the knowledge block; `searchTool` gives the model a
tool that searches them with the tenant's embeddings.

### Memory [#memory]

`withMemory` adds the user's core memory to the instructions, and
`memoryTool` lets the model save and recall facts. Memory is per user and
per organization.

### Tenant keys, metering and caching [#tenant-keys-metering-and-caching]

The example's `run` adds the organization's own provider keys with
`byokOptions`, so a tenant that saved an Anthropic key pays for its own
calls ([tenant keys](/docs/ai-sdk/providers)). Register
[`meterTelemetry`](/docs/ai-sdk/telemetry) in `instrumentation.ts` to meter
every call on the usage block, and wrap deterministic calls such as titles
or summaries with [`cacheMiddleware`](/docs/ai-sdk/cache).

## Beyond the example [#beyond-the-example]

The blocks hold more than the example shows:

* Plans gate models through `ai_model_catalog` and `usageQuota`; an empty
  quota answers with a 429 and `Retry-After`.
* [Agents](/docs/ai-sdk/agents) run as a `ToolLoopAgent` with tool approvals,
  and [MCP connectors](/docs/ai-sdk/mcp) keep each user's tokens in Vault.
* [AI tasks](/docs/blocks/ai-tasks) run a prompt on a cron, and
  [batches](/docs/ai-sdk/batches) run thousands at the batch price.
* Sharing by link (`shares.create`), temporary chats that `chats.purge`
  removes, feedback and moderation events are chat block calls.

# Build a unibox inbox

> The modules, routes and components behind the example's /inbox page and Help sheet, where the assistant answers first and staff take over, plus the channel adapters that bring Slack and WhatsApp into the same list.

Source: https://bettersupabase.com/docs/build/unibox-inbox

The Next.js example has a staff inbox at `/inbox` and a Help sheet in the
header. A signed-in user asks a question in the sheet, the assistant from
[Build a ChatGPT clone](/docs/build/chatgpt-clone) answers, and staff see
the conversation in `/inbox`, where a reply takes it over. Conversations
from Slack, WhatsApp or SMS land in the same list through the
[Chat SDK channel helpers](/docs/chat-sdk/channels). Sign in as
`member@acme.test` to ask, and as `admin@acme.test` in another browser to
answer (password `password123`).

## The modules [#the-modules]

```ts title="better-supabase.config.ts"
sql: {
  modules: {
    jobs: {},
    streams: { api: "api" },
    inbox: { api: "api" },
    "chat-sdk-state": {},
    "ai-chat": { api: "api" },
  },
},
```

| Module           | Gives the inbox                                                               | Page                                  |
| ---------------- | ----------------------------------------------------------------------------- | ------------------------------------- |
| `inbox`          | inboxes, contacts, conversations, messages, notes, receipts and the bot mode  | [Inbox](/docs/blocks/inbox)           |
| `jobs`           | the `inbox_bot` and `inbox_outbound` queues                                   | [Jobs](/docs/blocks/jobs)             |
| `streams`        | resumable bot output                                                          | [Streams](/docs/blocks/streams)       |
| `chat-sdk-state` | Chat SDK's subscriptions, locks and cache on Postgres                         | [State adapter](/docs/chat-sdk/state) |
| `ai-chat`        | the assistant's chats; the Help conversation's chat has the conversation's id | [AI chat](/docs/blocks/ai-chat)       |

The example's access contract gives `member` `inbox.read` and
`inbox.reply`, and `admin` also `inbox.assign` and `inbox.manage`.

### The staff inbox [#the-staff-inbox]

`/inbox` loads conversations with `conversations.list` and keeps them live
with `useInbox`. The conversation panel shows messages, internal notes,
read receipts and typing; the composer sends replies and notes. Assigning,
snoozing and resolving go through `src/features/inbox/inbox-actions.ts`.

### The Help sheet [#the-help-sheet]

`useInboxWidget` in `help-sheet.tsx` opens a conversation for the signed-in
user on the first question. `askForHelp` opens it with `botMode: "bot"`, so
the assistant answers until a staff member replies.

### The assistant answers [#the-assistant-answers]

`answerHelp` in `src/features/inbox/help-bot.ts` runs inside `after()`,
with the user's session, once the message is saved:

1. It loads the conversation and returns when `botMode` is no longer `bot`.
2. It shows the bot as typing and sends the question to the assistant's
   `respond` with the conversation id as the chat id, so the assistant keeps
   the thread's history.
3. It collects the text deltas, checks `botMode` again, and posts the answer
   as a Markdown message with the author type `bot`.

A staff reply switches the conversation to `human`, so the next question
goes to staff only.

### Channels [#channels]

To add Slack or WhatsApp, record the installation with
`createChatInstallations`, receive the platform's webhooks with
`webhook()`, replay them into your Chat SDK instance with `inboundHandler`,
and deliver staff replies with `deliver()`. The token stays in the
credential provider; the installation row keeps its `credential_ref`.

### Bots on a queue [#bots-on-a-queue]

The module also queues an `inbox_bot` job for each contact message in bot
mode. A bot that runs without the user's session, for example a Chat SDK
bot behind [`inboxAdapter`](/docs/chat-sdk/inbox-adapter), drains that
queue:

```ts title="app/api/jobs/drain/route.ts"
export const GET = jobs.drainRoute({
  secret: process.env.CRON_SECRET,
  handlers: {
    inbox_bot: (payload) => inboxBot.dispatch(payload),
    inbox_outbound: (payload) => send(payload),
  },
});
```

The example answers inline instead, so it has no drain route and the
queued `inbox_bot` jobs stay unclaimed. In production, either drain the
queue with a handler that does nothing, or move the answer into a handler
that loads the conversation's contact through the service role.

# Build a workflow builder

> The modules, routes and components behind the example's /workflows pages, where members draw a graph, publish it and watch each run on the canvas.

Source: https://bettersupabase.com/docs/build/workflow-builder

The Next.js example's `/workflows/builder` page lets members of a tenant
draw a workflow as a graph, publish it, and start it by hand, from a
webhook, on a schedule or on an event. Each run shows on the canvas node by
node. The graph lives in the [workflow builder block](/docs/blocks/workflow-builder);
the [Workflow SDK](/docs/blocks/workflow-sdk) runs it. Sign in as
`admin@acme.test` (password `password123`) to edit and publish.

## The modules [#the-modules]

```ts title="better-supabase.config.ts"
sql: {
  modules: {
    jobs: {},
    credentials: { api: "api" },
    workflows: { api: "api" },
    "workflow-sdk-world": {},
    "workflow-builder": { api: "api" },
  },
},
```

| Module               | Gives the builder                                                    | Page                                              |
| -------------------- | -------------------------------------------------------------------- | ------------------------------------------------- |
| `workflows`          | the engine-neutral run registry, schedules, semaphores and admission | [Workflows](/docs/blocks/workflows)               |
| `workflow-sdk-world` | the Workflow SDK's storage and queue on Postgres                     | [Workflow SDK](/docs/blocks/workflow-sdk)         |
| `workflow-builder`   | definitions, versions, triggers, credentials, node runs and alerts   | [Workflow builder](/docs/blocks/workflow-builder) |
| `credentials`        | the Vault functions credential rows resolve through                  | [Credentials](/docs/extending/credentials)        |

Grant the `workflow.*` keys to your roles. The example's access contract
(`supabase/schemas/045_access_contract.sql`) gives `owner` and `admin` all
of them, and `member` `workflow.read` and `workflow.run`.

### The server [#the-server]

`builderFor(supabase)` in `src/features/workflows/builder/builder-server.ts`
creates the block as the caller, with a service transport for webhooks,
events and secrets. Three options connect it to the Workflow SDK:

| Option    | Example value                       | Does                                                    |
| --------- | ----------------------------------- | ------------------------------------------------------- |
| `steps`   | `stepLibrary`                       | the steps the canvas offers, synced to the step library |
| `compile` | `compileGraph`                      | turns a published graph into the engine's form          |
| `start`   | `graphStarter({ steps, executor })` | starts a run of a published version                     |

`graph-steps.ts` holds the step functions, keyed by the names in the step
library, and `graph-workflow.ts` the `"use workflow"` executor that walks a
compiled graph.

### The canvas [#the-canvas]

The editor in `src/features/workflows/builder/components` draws nodes and
edges with React Flow. Saving writes a draft version; `validateGraph` checks
it for cycles, missing steps and unreachable nodes before the publish dialog
lets a member publish. `diffGraphs` shows what changed since the published
version.

### Triggers [#triggers]

The trigger sheet adds a manual, webhook, schedule or event trigger.
Webhook calls arrive on `/api/workflows/hooks/[token]`; the token's SHA-256
is all the table keeps. `/api/workflows/tick` runs every minute from a cron
and starts due schedules and queued admission requests through
`triggers.starter`.

### Credentials [#credentials]

The credential sheet stores a secret in Vault and saves only the
`credential_ref` on the tenant's row. Steps read it at run time through
`credentials.resolve`, so a rotated secret takes effect on the next run.

### Run status [#run-status]

Each step records its node run, so `/workflows/[run]` and the canvas show
which node is running, waiting for an approval, done or failed, with its
output and error. Alerts on a definition fire once per failed or slow run.

## Another engine [#another-engine]

The graph and the run registry don't depend on the Workflow SDK. An engine
provides a `GraphCompiler` and a `BuilderStarter`, and reports its runs with
`workflows.runs.record`. [Add another SDK](/docs/extending/add-an-sdk)
describes the contract for a Temporal adapter.

# Channels

> Record Slack, WhatsApp, Messenger and SMS conversations in the inbox with Chat SDK adapters, deliver staff replies on their channel and keep the event store clean.

Source: https://bettersupabase.com/docs/chat-sdk/channels

These helpers in `better-supabase/chat-sdk` put conversations from a
Chat SDK platform adapter into the [inbox](/docs/blocks/inbox), and post
staff replies back on the same platform. All of them take `createInbox` on
a service transport.

## Installations [#installations]

`createChatInstallations` records which workspace or number an adapter is
installed on, per organization. The token stays in the
[credential provider](/docs/extending/credentials); the row only keeps its
`credential_ref`.

```ts title="lib/installations.ts"
import {
  createChatInstallations,
  sqlTransport,
} from "better-supabase/chat-sdk";

export const installations = createChatInstallations({
  transport: sqlTransport(postgres.asService()),
  credentials,
});

await installations
  .install({
    adapter: "slack",
    externalId: teamId,
    tenant: organizationId,
    credentialRef,
  })
  .orThrow();
```

A reinstall revokes the credential it replaces, `uninstall(adapter, externalId)`
revokes the current one, and `uninstallTenant(tenant)` removes every live
install of an organization before it is deleted. `get` and `list` read
them back. Organization exports leave out `credential_ref`, and the
[organization purge](/docs/blocks/data-lifecycle#credentials) keeps the
installation rows and doesn't revoke them, so call `uninstallTenant` first.

## Building an adapter with its token [#building-an-adapter-with-its-token]

`channelAdapter` asks the credential provider for the install's token and
hands it to the adapter factory:

```ts
import { createSlackAdapter } from "@chat-adapter/slack";
import { channelAdapter } from "better-supabase/chat-sdk";

const install = await installations.get("slack", teamId).orThrow();
const slack = await channelAdapter({
  credentials,
  ref: install.credentialRef,
  create: (token) => createSlackAdapter({ botToken: token.token }),
});
```

## Receiving webhooks [#receiving-webhooks]

`webhook()` returns a route handler that verifies the request, stores the
raw event in the inbox's event table and answers `200` at once. Platforms
retry slow endpoints, so the processing happens afterwards:

```ts title="app/api/chat/whatsapp/route.ts"
import { after } from "next/server";
import { webhook } from "better-supabase/chat-sdk";

import { handler, inbox } from "@/lib/chat";

export const POST = webhook({
  inbox,
  adapter: "whatsapp",
  verify: (request, body) => verifyMetaSignature(request, body),
  externalId: (body) => JSON.parse(body).entry?.[0]?.id,
  after: () => after(() => handler.drain()),
});
export const GET = POST;
```

| Option                              | Meaning                                                                          |
| ----------------------------------- | -------------------------------------------------------------------------------- |
| `adapter`                           | The adapter name the event is replayed into                                      |
| `credentials` and `ref`             | Verify the request with `credentials.verifyInbound` before storing it            |
| `verify`                            | Verifies the request when no credential ref does                                 |
| `externalId`                        | The platform's event id, so a retried delivery is stored once                    |
| `passthrough`, `passthroughHandler` | Requests answered synchronously, by default `GET` and Slack's `url_verification` |
| `after`                             | Runs after the event is stored                                                   |

Without `credentials` or `verify`, every request is stored, so set one in
production.

## Replaying events into Chat SDK [#replaying-events-into-chat-sdk]

`inboundHandler` replays stored events into your `Chat` instance and
records channel messages in the inbox:

```ts title="lib/chat.ts"
import { inboundHandler } from "better-supabase/chat-sdk";

export const handler = inboundHandler({
  chat: bot,
  inbox,
  inboxes: { whatsapp: whatsappInboxId, slack: slackInboxId },
});

bot.onNewMessage(/.*/, async (thread, message) => {
  await handler.mirror(thread, message);
});
```

`drain({ limit, maxAttempts })` processes pending events oldest first and
returns `{ processed, failed }`. Delivery status callbacks from WhatsApp,
Messenger, Instagram and Twilio update the matching delivery instead of
reaching Chat SDK; pass `parsers` to add more. `mirror` finds or opens the
conversation for the thread, upserts the contact from the message's author
and skips bot messages and the echoes of replies `deliver` posted.

Platform file URLs usually need the bot's token. Pass `copyFile` to copy
each file into the `inbox-files` bucket; without it only files with a
public URL are kept.

## Delivering staff replies [#delivering-staff-replies]

A staff reply on a channel inbox queues an `inbox_outbound` job.
`deliver()` posts it with the conversation's adapter, records the delivery
with the platform's message id and throws on failure, so the job retries:

```ts title="app/api/jobs/drain/route.ts"
import { deliver, whatsappWindowOpen } from "better-supabase/chat-sdk";

const send = deliver({
  chat: bot,
  inbox,
  template: async ({ conversation }) =>
    conversation.inbox?.channel === "whatsapp" &&
    !(await whatsappWindowOpen(inbox, conversation.id))
      ? { markdown: "We replied to your question. Answer here to continue." }
      : null,
});

export const GET = jobs.drainRoute({
  secret: process.env.CRON_SECRET,
  handlers: { inbox_outbound: (payload) => send(payload) },
});
```

`template` replaces the message when the channel's reply window is closed,
such as WhatsApp's 24 hours after the contact's last message. The adapter
defaults to the inbox's channel (`sms` posts with `twilio`); `adapterFor`
picks another.

## Maintenance [#maintenance]

`maintain` runs the periodic work: it reopens snoozed conversations whose
time came, replays events a crashed request left pending, deletes
processed events after `keepEvents` (7 days by default) and purges expired
[state](/docs/chat-sdk/state).

```ts title="app/api/cron/inbox/route.ts"
import { maintain } from "better-supabase/chat-sdk";

export async function GET() {
  return Response.json(await maintain({ inbox, handler, state }));
}
```

Protect the route with your cron secret like the jobs drain route.

# Inbox adapter

> inboxAdapter, a Chat SDK adapter whose threads are inbox conversations, so a bot answers visitors in the in-app widget and staff can take over.

Source: https://bettersupabase.com/docs/chat-sdk/inbox-adapter

`inboxAdapter` makes the [inbox](/docs/blocks/inbox) a Chat SDK platform.
A thread is a conversation (`inbox:<conversationId>`), the bot's replies
are messages with the author type `bot`, and reactions, edits, typing and
read state go through the same `createInbox` client.

```ts title="lib/bot.ts"
import { createInbox, sqlTransport } from "better-supabase/blocks/inbox";
import { inboxAdapter } from "better-supabase/chat-sdk";

const inbox = createInbox({ transport: sqlTransport(postgres.asService()) });

export const inboxBot = inboxAdapter({
  inbox,
  userName: "Acme bot",
  streams,
});
```

| Option     | Default  | Meaning                                                                         |
| ---------- | -------- | ------------------------------------------------------------------------------- |
| `inbox`    | required | `createInbox` on a service transport; the bot writes as the service             |
| `userName` | `bot`    | The bot's name on its messages and reactions                                    |
| `verify`   | none     | Checks requests to `handleWebhook`; without it `handleWebhook` answers 401      |
| `streams`  | none     | A [stream store](/docs/blocks/streams) for streamed replies a reader can resume |

## Running the bot [#running-the-bot]

The module queues an `inbox_bot` job when a contact writes in a
conversation whose `botMode` is `bot`. Hand the job to `dispatch`, which
loads the message and runs the bot's Chat SDK handlers. Messages from
staff, notes and other bots are skipped, so the bot never answers itself:

```ts
await inboxBot.dispatch({ conversation_id, message_id });
```

When the bot can't help, hand the conversation to staff. `handoff` sets
`botMode` to `human`, so later messages queue no bot job:

```ts
bot.onSubscribedMessage(async (thread, message) => {
  if (/human|agent/i.test(message.text)) {
    await inbox.conversations
      .handoff(conversationIdOf(thread.id), "asked for a person")
      .orThrow();
    await thread.post("Someone from the team will reply here.");
  }
});
```

`conversationIdOf(threadId)` turns a thread id back into a conversation
id. A staff reply in a bot conversation also switches it to `human`.

## Streamed replies [#streamed-replies]

A streamed `thread.post` shows the bot as typing while it writes, then
posts one message with the full text. With `streams`, the chunks also go
to the stream store as they arrive, and the message's `metadata.stream_id`
names the stream, so a client can replay the reply chunk by chunk.

## Limits [#limits]

Cards and modals render as their fallback text, and `scheduleMessage`,
`openModal` and `postEphemeral` aren't implemented: the in-app widget has
no surface for them.

# Chat SDK

> Run Chat SDK bots on Supabase with a Postgres state adapter, an inbox adapter for the in-app widget, and helpers that record Slack, WhatsApp and SMS messages in the inbox.

Source: https://bettersupabase.com/docs/chat-sdk

`better-supabase/chat-sdk` connects [Chat SDK](https://chat-sdk.dev) to
Supabase. It adds no tables of its own: the state lives in the
`chat-sdk-state` [SQL module](/docs/blocks/sql), and conversations live in
the [inbox](/docs/blocks/inbox).

```bash
pnpm add chat
pnpm better-supabase sql add chat-sdk-state inbox
```

`chat` is an optional peer (`>=4.41 <5`), so apps that don't run a bot
never install it. Every platform adapter, such as `@chat-adapter/slack`, is
the app's own dependency.

| Page                                          | Covers                                                                |
| --------------------------------------------- | --------------------------------------------------------------------- |
| [State](/docs/chat-sdk/state)                 | `createSupabaseState`, the `StateAdapter` on Postgres                 |
| [Inbox adapter](/docs/chat-sdk/inbox-adapter) | `inboxAdapter`, a bot that answers in the in-app widget               |
| [Channels](/docs/chat-sdk/channels)           | `webhook`, `inboundHandler`, `deliver`, `maintain` and installations  |
| [React](/docs/chat-sdk/react)                 | `better-supabase/chat-sdk/react`, the inbox hooks for chat interfaces |

## How a message flows [#how-a-message-flows]

1. A platform webhook reaches the route from `webhook()`, which verifies
   it, stores it in the inbox's event table and answers at once.
2. `inboundHandler().drain()` replays the stored event into Chat SDK.
   Your handlers call `handler.mirror(thread, message)`, which records the
   message in the inbox, so staff see it next to widget conversations.
3. A staff reply queues an `inbox_outbound` job; `deliver()` posts it with
   the platform adapter and records the delivery.
4. In `bot` mode, a contact's message queues an `inbox_bot` job instead,
   and `inboxAdapter().dispatch()` runs your bot's handlers on it.
5. `maintain()` on a cron reopens snoozed conversations, replays events a
   crashed request left, purges old events and deletes expired state.

A minimal bot on the widget:

```ts title="lib/bot.ts"
import "server-only";

import { Chat } from "chat";
import { createInbox, sqlTransport } from "better-supabase/blocks/inbox";
import { createSupabaseState, inboxAdapter } from "better-supabase/chat-sdk";

const transport = sqlTransport(postgres.asService());
export const inbox = createInbox({ transport });
export const inboxBot = inboxAdapter({ inbox, userName: "Acme bot" });

export const bot = new Chat({
  userName: "Acme bot",
  adapters: { inbox: inboxBot },
  state: createSupabaseState({ transport }),
});

bot.onNewMention(async (thread, message) => {
  await thread.post(`You wrote: ${message.text}`);
});
```

Run the bot jobs from a [jobs](/docs/blocks/jobs) drain route. Declare
`inbox_bot` in your queues with the `InboxBotJob` shape
(`conversation_id`, `message_id`), then:

```ts title="app/api/jobs/drain/route.ts"
import "server-only";

import { inboxBot } from "@/lib/bot";
import { jobs } from "@/lib/jobs";

export const GET = jobs.drainRoute({
  secret: process.env.CRON_SECRET,
  handlers: {
    inbox_bot: (payload) => inboxBot.dispatch(payload),
  },
});
```

## Credentials [#credentials]

Platform tokens never sit in the inbox tables. An installation stores a
`credential_ref`, and `channelAdapter` asks the
[credential provider](/docs/extending/credentials) for the token each time
it builds an adapter, so a rotated or revoked token takes effect on the
next message.

# React

> better-supabase/chat-sdk/react re-exports the inbox hooks for chat interfaces, a staff list, a thread with typing, and an in-app widget.

Source: https://bettersupabase.com/docs/chat-sdk/react

`better-supabase/chat-sdk/react` re-exports `useInbox`, `useConversation`
and `useInboxWidget` from `better-supabase/blocks/inbox/react`, so a chat
interface imports its hooks from the same place as its bot. They are the
same functions; see [Inbox](/docs/blocks/inbox#realtime) for their options.

```tsx title="components/help-chat.tsx"
"use client";

import { useInboxWidget } from "better-supabase/chat-sdk/react";

import { askForHelp, loadMessages, sendHelp } from "./help-actions";

export function HelpChat({ userId }: { userId: string }) {
  const widget = useInboxWidget({
    storageKey: `help:${userId}`,
    open: (message) => askForHelp({ message }),
    send: (conversationId, message) => sendHelp({ conversationId, message }),
    load: (conversationId) => loadMessages({ conversationId }),
  });

  return (
    <form action={(data) => widget.submit(String(data.get("message")))}>
      <ul>
        {widget.items?.map((m) => (
          <li key={m.id}>{m.body}</li>
        ))}
      </ul>
      {widget.typing.length > 0 ? <output>Typing</output> : null}
      <textarea name="message" onInput={() => widget.setTyping(true)} />
    </form>
  );
}
```

`useInboxWidget` returns the thread state of `useConversation` (`items`,
`typing`, `setTyping`) plus `conversationId`, `submit` and `reset`.

Each hook loads through a server action you pass as `load`, and reloads
when a broadcast arrives on the private inbox topics, so the browser never
needs the tables in the Data API. A bot's replies arrive the same way as a
person's.

Cards and modals from Chat SDK aren't rendered here; the
[inbox adapter](/docs/chat-sdk/inbox-adapter#limits) posts their fallback
text.

# State

> createSupabaseState, a Chat SDK StateAdapter on Postgres with subscriptions, locks, a cache, lists and per-thread queues.

Source: https://bettersupabase.com/docs/chat-sdk/state

`createSupabaseState` implements the Chat SDK `StateAdapter` on the tables
of the `chat-sdk-state` [SQL module](/docs/blocks/sql), so a bot needs no
Redis. Every call is one `security definer` function that only the service
role may run.

```bash
pnpm better-supabase sql add chat-sdk-state
```

```ts title="lib/bot.ts"
import { Chat } from "chat";
import { sqlTransport } from "better-supabase/blocks/inbox";
import { createSupabaseState } from "better-supabase/chat-sdk";

export const state = createSupabaseState({
  transport: sqlTransport(postgres.asService()),
  keyPrefix: "support-bot",
});

export const bot = new Chat({ userName: "Acme bot", adapters, state });
```

| Option      | Default           | Meaning                                               |
| ----------- | ----------------- | ----------------------------------------------------- |
| `transport` | required          | A service transport, `sqlTransport` or `rpcTransport` |
| `schema`    | `better_supabase` | The schema of the `chat-sdk-state` module             |
| `keyPrefix` | `chat-sdk`        | Separates bots that share the tables                  |
| `mappers`   | none              | Error mappers for the `DbError` the transport returns |

## What it stores [#what-it-stores]

| Chat SDK method                                                | Behavior                                                            |
| -------------------------------------------------------------- | ------------------------------------------------------------------- |
| `subscribe`, `unsubscribe`, `isSubscribed`                     | One row per subscribed thread                                       |
| `acquireLock`, `extendLock`, `releaseLock`, `forceReleaseLock` | One holder per thread; release and extension check the lock's token |
| `get`, `set`, `setIfNotExists`, `delete`                       | JSON values with an optional TTL                                    |
| `appendToList`, `getList`                                      | Ordered lists trimmed to `maxLength`, with a TTL                    |
| `enqueue`, `dequeue`, `queueDepth`                             | A per-thread queue that keeps the newest `maxSize` entries          |

Expired rows are ignored at once and deleted by `state.purge({ batch })`,
which returns how many rows went. Call it from a cron, or pass the state to
[`maintain`](/docs/chat-sdk/channels#maintenance).

## Testing another state adapter [#testing-another-state-adapter]

`testChatState` from `better-supabase/testing` runs the same contract
against any `StateAdapter`. It needs a message factory for the queue
checks:

```ts title="tests/state.test.ts"
import { createTestMessage } from "@chat-adapter/tests";
import { testChatState } from "better-supabase/testing";
import { test } from "vitest";

test("the state adapter conforms", async () => {
  await testChatState(state, { message: createTestMessage });
});
```

The kit throws a `ConformanceError` that lists every failed check. See
[Conformance kits](/docs/extending/conformance).

# codemod

> Rewrite imports and calls for renamed better-supabase APIs after an upgrade.

Source: https://bettersupabase.com/docs/cli/codemod

`better-supabase codemod` rewrites your source for the renames in a release,
so an upgrade is a command and a review instead of a search through the
codebase. Run it after you update the package:

```bash
pnpm better-supabase codemod 0.6 --dry-run
pnpm better-supabase codemod 0.6
```

Pass files or directories after the name to limit it (`codemod 0.4 src app`).
Without them it walks the project and skips `node_modules`, build output
(`dist`, `build`, `.next`, `.turbo`, `.vercel`, `coverage`) and `.d.ts` files.
`--dry-run` prints a unified diff of each file it would change and writes
nothing.

| Codemod | What it changes                                                                                                                                                                                                                                                                         |
| ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0.4`   | The 0.4 renames on the [naming page](/docs/concepts/naming#renames): imports, members and the provider prop                                                                                                                                                                             |
| `0.5`   | `scopes` in `createMcp()` options becomes `advertisedScopes`                                                                                                                                                                                                                            |
| `0.6`   | Renames the `Kit` and `Org` exports to their `Block`, `Module` and `Organization` names and `maxUrlLength` to `urlLengthLimit`; lists every `$rpc` call (rows in the configured casing), `.publicUrl(` and `.path({` calls (a `Result`) and imports from the moved subpaths, for review |

## What it rewrites [#what-it-rewrites]

Each codemod makes narrow, mechanical renames. It reads the source with a
small scanner that skips strings, template literals and comments, so a name
in a message or a comment stays as it is.

* **Imports.** A renamed export imported from `better-supabase` or one of its
  subpaths is renamed in the import and wherever the file uses it. An aliased
  import (`Postgres as PG`) keeps the alias, and imports from other modules
  are left alone.
* **Members.** Renamed methods and properties after a `.`, such as
  `next.serverFor(` to `next.contextForSession(` and `repository.$table` to
  `repository.$tableName`. `db.$table("notes")`, which still exists, is
  skipped.
* **Props.** `browser=` on `<BetterSupabaseProvider>` becomes `client=`.
* **Options.** A key at the top level of an object literal passed to a named
  call: `createMcp(betterSupabase, { scopes })` becomes `{ advertisedScopes:
  scopes }`. Nested objects keep their keys.

## What it leaves for you [#what-it-leaves-for-you]

Some renames depend on what a variable holds (`next.server()` is
`bs.context()` only on a `createNext()` instance). The codemod doesn't guess:
it lists those lines after the summary, as `path:line: message`, so you can
change them by hand.

```text
Updated 3 of 41 files:
  src/lib/supabase/client.ts
  src/app/providers.tsx
  src/app/api/mcp/route.ts

Check these by hand:
  src/app/page.tsx:12: `next.server()` is `bs.context()` on the createNext() instance
```

Run your type checker afterwards; a rename the codemod missed shows up there.
With `--json`, the result is `{ codemod, dryRun, changed, review }`.

SQL module renames are not source rewrites: `better-supabase sql upgrade`
handles them, and doctor reports SQL that still uses an old name (BS309).

# Configuration

> better-supabase.config.ts options and defaults.

Source: https://bettersupabase.com/docs/cli/config

The CLI looks for `better-supabase.config.{ts,mts,js,mjs,json}` in the
working directory, then in each parent directory up to the one that holds
`.git`, and loads the first it finds. Paths in the file (`output`, `sql.dir`
and the rest) are relative to the directory that holds it, so a config at the
repository root works when the CLI runs from a package. `--config` overrides
the search. TypeScript configs load natively on Node 24.

```ts title="better-supabase.config.ts"
import { defineConfig, zod } from "better-supabase/config";
import { CustomerMetadata } from "./src/types.ts";

export default defineConfig({
  source: { projectRef: "abcdefghijklmnopqrst" },
  schemas: ["public"],
  casing: "camel",
  output: "src/lib/supabase/generated.ts",
  codecs: { int8: "bigint", numeric: "string", timestamptz: "instant" },
  json: {
    "customers.metadata": {
      import: "./src/types.ts#CustomerMetadata",
      schema: CustomerMetadata,
    },
    "notes.attachments": {
      type: "{ files: { name: string; size: number }[] }",
    },
  },
  sensitive: ["contacts.email", "contacts.phone"],
  tables: {
    customers: { relations: { primaryContact: "contact" } },
    internal_jobs: { exclude: true },
  },
  plugins: {
    timestamps: true,
    softDelete: { column: "archived_at" },
    tenant: { column: "organization_id" },
    actor: true,
  },
  realtime: { tables: ["customers", "notes"] },
  generators: [zod()],
});
```

## Options [#options]

| Option             | Default                                                                                      | Notes                                                                                                                                                                                                                                                                                                                                                                                              |
| ------------------ | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `source`           | local stack                                                                                  | `dbUrl`, `projectRef` (+ `accessToken`, default `$SUPABASE_ACCESS_TOKEN`), `snapshot` or `lite`. See [gen](/docs/cli/gen#where-the-schema-comes-from)                                                                                                                                                                                                                                              |
| `schemas`          | `['public']`                                                                                 | Tables in other schemas get prefixed keys                                                                                                                                                                                                                                                                                                                                                          |
| `casing`           | `'snake'`                                                                                    | `'camel'` maps columns in queries; see [casing](/docs/concepts/casing)                                                                                                                                                                                                                                                                                                                             |
| `tables`           | `{}`                                                                                         | Per-table `casing`, `exclude`, `serviceRole`, relation renames and `insertOptional`, keyed by table name or `schema.table`. `serviceRole` keeps the models but tells [doctor](/docs/cli/doctor#bs106) the table is for `service_role` only; `insertOptional` lists not-null columns the database fills on insert                                                                                   |
| `output`           | `src/lib/supabase/generated.ts`                                                              | `database.types.ts` and `generated.meta.js` are written next to it                                                                                                                                                                                                                                                                                                                                 |
| `postgrestVersion` | `'13'`                                                                                       | Written to `__InternalSupabase` in `database.types.ts`                                                                                                                                                                                                                                                                                                                                             |
| `codecs`           | JSON types                                                                                   | `int8: 'bigint' \| 'string'`, `numeric: 'string'`, `timestamptz: 'instant'` read values exactly; see [Temporal](/docs/concepts/temporal)                                                                                                                                                                                                                                                           |
| `relations`        | `{ nullableUnderRls: false }`                                                                | `nullableUnderRls: true` types to-one includes of tables with RLS as `\| null`; see [includes](/docs/repository/includes#row-level-security)                                                                                                                                                                                                                                                       |
| `json`             | `{}`                                                                                         | jsonb types, keyed by `table.column` or `schema.table.column` (database names). `schema` adds a [database check](/docs/blocks/sql#enforcing-jsonb-shapes)                                                                                                                                                                                                                                          |
| `sensitive`        | `[]`                                                                                         | Columns the [`noSensitiveSelect` rule](/docs/plugins/rules) guards (`table.column`)                                                                                                                                                                                                                                                                                                                |
| `generators`       | `[]`                                                                                         | `zod()`, `valibot()`, `jsonSchema()`, `standardSchema()` or your own                                                                                                                                                                                                                                                                                                                               |
| `claims`           | `{ tenant: 'tenant_id', scope: 'tenant', features: 'features', memberships: 'memberships' }` | JWT claim names. `tenant` is the active-tenant claim the tenant plugin, `current_tenant_id()` and the storage and realtime policies read; `scope` is the `scope` of the module's `memberships` entries; `features` is the plan-features claim. See [Claims](#claims)                                                                                                                               |
| `plugins`          | none                                                                                         | Flags for timestamps, soft delete, tenant and actor columns                                                                                                                                                                                                                                                                                                                                        |
| `buckets`          | `{}`                                                                                         | Typed storage buckets; doctor compares them with the database and `config.toml`                                                                                                                                                                                                                                                                                                                    |
| `storagePaths`     | `{}`                                                                                         | Text columns that hold object paths, keyed by `table.column`, with a `buckets` key or bucket id as the value. They're typed as [`StoragePath<'bucket-id'>`](/docs/platform/storage#path-columns)                                                                                                                                                                                                   |
| `functions`        | `{}`                                                                                         | Function results that are never null, keyed by function name or `schema.name`: `{ notNull: true }` or the `returns table` columns. See [gen](/docs/cli/gen)                                                                                                                                                                                                                                        |
| `topics`           | `{}`                                                                                         | Realtime topic templates                                                                                                                                                                                                                                                                                                                                                                           |
| `realtime`         | `{ tables: [], global: [], users: {} }`                                                      | Tables for [live queries](/docs/frontend/live-queries); `global` lists the ones without a tenant column, `users` maps per-user tables to their user column                                                                                                                                                                                                                                         |
| `entitlements`     | `{ key: 'id' }`; `customer` defaults to the organizations module's `stripe_customer_id`      | Where the [`entitlements` SQL module](/docs/blocks/entitlements) finds each tenant's Stripe customer, or `source` (`{ plans }` or `"custom"`) for entitlements without the Stripe Sync Engine. `memberships` (`"tenant"` or `"provider"`) picks where it reads memberships from                                                                                                                    |
| `authorization`    | none                                                                                         | An [authorization provider](/docs/extending/authorization-providers) the `provider` access model, the entitlements module, bucket and topic policies and doctor delegate to                                                                                                                                                                                                                        |
| `vectorSearch`     | `{}`                                                                                         | Embedding columns (`{ chunks: 'embedding' }`) the [`vector-search` SQL module](/docs/blocks/vector-search) writes `search_<table>` for                                                                                                                                                                                                                                                             |
| `readSets`         | `[]`                                                                                         | Modules exporting [read sets](/docs/repository/read-sets); `gen` compiles them into the `read-sets` SQL module                                                                                                                                                                                                                                                                                     |
| `sql`              | `supabase/schemas`, `900_better_supabase`, `supabase/tests`                                  | Where generated SQL and pgTAP files go. `modules` lists the [SQL modules](/docs/blocks/sql) to keep in sync: a list of names, or an object keyed by module name whose values set its mode, schema, table and column names, id type and permission keys ([existing tables](/docs/blocks/sql#existing-tables-managed-adopt-and-custom)). `access` also picks the [access model](/docs/blocks/access) |
| `seed`             | `supabase/seed.ts`, `supabase/seeds/000_better_supabase.sql`                                 | Entry module and output of [`seed`](/docs/cli/local#seed)                                                                                                                                                                                                                                                                                                                                          |
| `specs`            | `src/lib/openapi.ts`, one OpenAPI 3.1 output at `openapi.json`, `failOn: 'error'`            | Entry module, outputs (`format`, `version`, `output`, `overlays`, `serialize`, `ui`) failure level and `manifest` file of [`spec`](/docs/cli/spec#configure-the-outputs)                                                                                                                                                                                                                           |
| `openapi`          | `src/lib/openapi.ts`, `openapi.json`                                                         | Deprecated alias of `specs`. Entry module and output of [`openapi emit`](/docs/cli/local#openapi-emit); `spec` reads them when `specs` is unset                                                                                                                                                                                                                                                    |
| `doctor`           | `format: 'text'`                                                                             | `ignore` finding codes, `strict` mode, `claimsLimit`, `policyHelperLimit`, and the defaults of `--only`, `--format` and `--out` (`only`, `format`, `output`)                                                                                                                                                                                                                                       |
| `gen`              | every configured task, `watchInterval: 2000`                                                 | `tasks` [`gen`](/docs/cli/gen#tasks) runs (`types`, `sql`, `spec`, `seed`, `scaffold`, `env`) and the `--watch` interval, which `spec --watch` reads too                                                                                                                                                                                                                                           |
| `env`              | `output: '.env.local'`, prefix from the framework                                            | Defaults of [`env`](/docs/cli/local#env) `--out` and `--prefix` (`output`, `prefix`; `''` for no prefix)                                                                                                                                                                                                                                                                                           |
| `scaffold`         | none                                                                                         | `api`: the `framework`, `tables`, `name` and `basePath` of [`scaffold api`](/docs/cli/scaffold). Setting it adds the `scaffold` task to `gen`                                                                                                                                                                                                                                                      |
| `integrations`     | `[]`                                                                                         | The integrations `init` set up. [`add`](/docs/cli/init#add) with no argument sets up the ones that have no files yet                                                                                                                                                                                                                                                                               |
| `skills`           | detected agent folders                                                                       | `agents`: the folders [`skills install`](/docs/for-ai-agents) writes to (`cursor`, `claude`, `agents`)                                                                                                                                                                                                                                                                                             |
| `keys`             | `supabase/signing_keys.json`                                                                 | `output`: where [`keys`](/docs/cli/local#keys) writes the signing key                                                                                                                                                                                                                                                                                                                              |
| `$env`, `$ci`, ... | none                                                                                         | Overrides per [environment](#environments)                                                                                                                                                                                                                                                                                                                                                         |

## Flags and the config [#flags-and-the-config]

Every option a command takes on each run has a config key, so the command
line stays short and a fresh checkout behaves the same as yours. A value is
picked in this order:

1. the flag, for one run;
2. the active [environment](#environments) block;
3. the config;
4. the default.

| Command          | Config key                                      |
| ---------------- | ----------------------------------------------- |
| `gen`            | `gen.tasks`, `gen.watchInterval`                |
| `env`            | `env.output`, `env.prefix`                      |
| `doctor`         | `doctor.only`, `doctor.format`, `doctor.output` |
| `spec`           | `specs.manifest`, `gen.watchInterval`           |
| `scaffold api`   | `scaffold.api`                                  |
| `add`            | `integrations`                                  |
| `skills install` | `skills.agents`                                 |
| `keys`           | `keys.output`                                   |

`doctor.output` applies when the report uses `doctor.format`, so
`doctor --json` prints to stdout even when the config writes SARIF to a
file. `scaffold api` and `add` print the config entry for the flags you
passed, so you can paste it once instead of repeating them.

## Environments [#environments]

An environment block overrides part of the config for one environment. Name
blocks under `$env`, or use the `$development`, `$production`, `$test` and
`$ci` shorthands:

```ts title="better-supabase.config.ts"
export default defineConfig({
  casing: "camel",
  doctor: { ignore: ["BS204"] },
  $ci: {
    gen: { tasks: ["types", "sql", "spec"] },
    doctor: { format: "sarif", output: "doctor.sarif", strict: true },
  },
  $env: {
    staging: { source: { projectRef: "abcdefghijklmnopqrst" } },
  },
});
```

The CLI applies the block named by `--env`, then by `$BETTER_SUPABASE_ENV`,
and in CI (`$CI` is set) the `ci` block. When both `$env.<name>` and a
shorthand exist for the same name, the shorthand applies last. Objects merge
key by key, while lists and other values replace the base value: above, `ci`
keeps `doctor.ignore` and replaces `gen.tasks`. An environment without a
block uses the config as it is. A block can't hold another block.

## Claims [#claims]

The `claims` block names the claims every part of better-supabase reads, so a
rename happens in one place. The defaults are:

```ts title="better-supabase.config.ts"
export default defineConfig({
  claims: {
    tenant: "tenant_id", // the active tenant, also read from app_metadata
    scope: "tenant", // memberships[].scope written by the tenant SQL module
    features: "features", // plan features per tenant, from the entitlements module
    memberships: "memberships", // the caller's memberships, from the tenant module or a provider's hook
  },
});
```

`gen` writes the names that differ from the defaults into the generated
schema, and `sql add` and `sql sync` render them into the SQL modules. Run
both after changing the block.

An adapter that reads these claims itself takes the paths from `claimPaths`
in `better-supabase/config` instead of hardcoding them. `DEFAULT_CLAIMS` holds
the defaults:

```ts
import { claimPaths } from "better-supabase/config";

const paths = claimPaths(config.claims);
// { tenant: ["tenant_id", "app_metadata.tenant_id"], features: "features",
//   memberships: "memberships", scope: "tenant" }
```

## supabase/config.toml [#supabaseconfigtoml]

The CLI reads `supabase/config.toml` for the local database port, the auth
settings [doctor](/docs/cli/doctor) checks and bucket drift. When
[`@supabase/config`](https://www.npmjs.com/package/@supabase/config) is
installed, it parses the file, with `env()` values filled in the same way the
Supabase CLI does. It's an optional peer (it needs `effect` and
`@effect/platform-node`). Without it, a built-in parser reads the subset of
TOML that `supabase init` writes.

## JSON configs [#json-configs]

```json title="better-supabase.config.json"
{
  "$schema": "https://unpkg.com/better-supabase/schemas/config-v1.json",
  "casing": "camel",
  "plugins": { "timestamps": true }
}
```

The JSON Schema gives editor completion and validation. Generators and
Standard Schema values in `json[...].schema` need a TypeScript or JavaScript
config; a JSON config can use plain JSON Schema objects.

## Print the resolved config [#print-the-resolved-config]

```bash
pnpm better-supabase config
```

`config` prints the file it loaded and the active environment, then the
config with every default filled in and that environment's block merged in,
as JSON (`--json` wraps them in one document, with `environment` set to the
name or `null`). `better-supabase config --env ci` shows what CI will see. Generators show as
their name and `apiVersion`, and the password in `source.dbUrl` is redacted.
Use it to check which file the CLI found and what an option resolved to.

`gen` warns about `tables` and `json` keys that match nothing in the
configured schemas, such as a misspelled table name, instead of ignoring them.

## Custom generators [#custom-generators]

```ts
import type { Generator } from "better-supabase/config";

export const tableList: Generator = {
  apiVersion: 1,
  name: "table-list",
  generate: ({ model }) => [
    {
      path: "src/lib/tables.json",
      contents: JSON.stringify(
        model.tables.map((table) => ({
          key: table.key,
          columns: table.columns.map((column) => [column.app, column.tsType]),
        })),
      ),
    },
  ],
};
```

A generator receives the schema metadata (`meta`), the typegen introspection,
the resolved config, an `importPath` helper and `model`: the tables and enums
`gen` emitted, with the TypeScript type it wrote for each column after `json`
overrides, codecs and enum unions. `model` is frozen. The generator returns
files, and `gen --check` covers them too.

`apiVersion: 1` names the generator contract it targets. `gen` refuses a
generator that declares a version it doesn't know, so a generator written for
a later contract fails with a message instead of misreading its input.
Leaving it out means version 1.

`gen` records the files it wrote in
`node_modules/.cache/better-supabase/gen-manifest.json`. When a later run no
longer writes one of them (you removed a generator or changed `output`), it
deletes the file and lists it as removed, and `gen --check` reports it as no
longer generated. Files that no earlier run wrote are never touched. Prove a
generator with [`testGenerator`](/docs/extending/conformance).

# doctor

> Security, performance and drift checks for your database, config.toml and env files.

Source: https://bettersupabase.com/docs/cli/doctor

```bash
pnpm better-supabase doctor
```

`doctor` introspects the database the same way [`gen`](/docs/cli/gen) does,
then reads `supabase/config.toml`, your `.env*` files and `.gitignore`. Every
finding has a code, a severity and a link to its section on this page. When a
finding is about a table, function or policy, doctor points at the file that
creates it: declarative schemas in `supabase/schemas` first, then the newest
migration.

It also runs Supabase's own [Security and Performance
Advisors](https://supabase.com/docs/guides/database/database-advisors) (BS100
and BS200), so the dashboard's findings show up locally and in CI. It exits
with 1 when there are errors, or with any warning under `--strict`.

| Option                 | Description                                                                                                                                                                                                                                                        |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--format <f>`         | `text` (default), `sarif` or `github`; also `doctor.format`. The global `--json` prints the JSON report                                                                                                                                                            |
| `--out <file>`         | Write the report to a file; also `doctor.output`, which applies with `doctor.format`                                                                                                                                                                               |
| `--only <codes>`       | Run only these checks, e.g. `--only BS100,BS304`; also `doctor.only`                                                                                                                                                                                               |
| `--ignore <codes>`     | Skip checks. Adds to `doctor.ignore` in the config                                                                                                                                                                                                                 |
| `--strict`             | Fail on warnings. Same as `doctor.strict`                                                                                                                                                                                                                          |
| `--snapshot <file>`    | Check a saved snapshot instead of the database                                                                                                                                                                                                                     |
| `--metadata <path\|->` | Check a `GeneratorMetadata` document (`-` for stdin). Checks that need policies, grants, indexes, triggers or other data it lacks are skipped with an info finding; it exits 65 for a document it rejects ([gen](/docs/cli/gen#from-a-generatormetadata-document)) |
| `--db-url-stdin`       | Read the connection string of the database to check from stdin                                                                                                                                                                                                     |
| `--project-ref <ref>`  | Hosted project to read through the Management API (needs `SUPABASE_ACCESS_TOKEN`)                                                                                                                                                                                  |
| `--stats`              | Report slow frequent statements from `pg_stat_statements` ([BS209](#bs209))                                                                                                                                                                                        |
| `--explain <tables>`   | Plan these tables under RLS ([BS212](#bs212))                                                                                                                                                                                                                      |
| `--as <uuid>`          | With `--explain`: plan as this authenticated user. Also measures the claims the custom access token hook returns for them ([BS405](#bs405))                                                                                                                        |
| `--claims <json>`      | With `--explain`: plan with these JWT claims                                                                                                                                                                                                                       |
| `--fix-grants`         | Print the grant and revoke SQL that [BS404](#bs404) asks for, as one block for a schema file or migration                                                                                                                                                          |

Doctor never prints env values, only variable names and line numbers.

## In CI [#in-ci]

`--format github` writes workflow annotations, so findings show up on the pull
request diff:

```yaml
- run: pnpm better-supabase doctor --format github --strict
```

The `github` and `sarif` formats write file paths relative to the repository
root (the directory that holds `.git`, else the Supabase project root), so
annotations land on the right files when the CLI runs from a package. The text
format keeps paths relative to the project directory.

For GitHub code scanning, upload SARIF:

```yaml
- run: pnpm better-supabase doctor --format sarif --out doctor.sarif
- uses: github/codeql-action/upload-sarif@v3
  if: always()
  with:
    sarif_file: doctor.sarif
```

To keep the workflow step to `better-supabase doctor`, put the CI settings in
the config's [`ci` environment](/docs/cli/config#environments), which the CLI
applies when `$CI` is set:

```ts title="better-supabase.config.ts"
export default defineConfig({
  $ci: { doctor: { format: "sarif", output: "doctor.sarif", strict: true } },
});
```

`doctor --json` prints one report that follows
[`doctor-report-v1.json`](https://unpkg.com/better-supabase/schemas/doctor-report-v1.json).

## Ignoring checks [#ignoring-checks]

```ts title="better-supabase.config.ts"
export default defineConfig({
  doctor: { ignore: ["BS204"], strict: true },
});
```

`doctor.sources` lists the app files that checks such as BS210 scan, as globs
relative to the project root. The default is `['src/**/*.{ts,tsx}']`.
`doctor.claimsLimit` is a BS405 limit in bytes: the budget of the
[authorization provider's](/docs/extending/authorization-providers) hook when
it sets `tokenHook.budget`, otherwise the limit for the whole token (default
2048\).
`doctor.policyHelperLimit` (default 5) is how many policies a security definer
helper may appear in before [BS206](#bs206) reports it.

The full `doctor` block, generated from the `DoctorConfig` type:

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `ignore?` | `readonly string[]` |  | Finding codes to skip, e.g. `['BS204']`. |
| `only?` | `readonly string[]` |  | Run only these checks, e.g. `['BS100', 'BS304']`. |
| `strict?` | `boolean` |  | Treat warnings as errors. |
| `format?` | `"github" \| "sarif" \| "text"` |  | Report format: `text` (default), `sarif` (code scanning) or `github` (workflow annotations). `--json` still wins. |
| `output?` | `string` |  | Write the report to this file. |
| `sources?` | `readonly string[]` |  | App source files doctor scans for API use (BS210). Globs relative to the root. Defaults to `['src/**\/*.{ts,tsx}']`. |
| `policyHelperLimit?` | `number` |  | How many policies a security definer helper may appear in before BS206 asks for an inlinable `language sql stable` function. Defaults to 5. |
| `claimsLimit?` | `number` |  | The BS405 limit in bytes. When the authorization provider's token hook has a budget, it replaces that budget and the whole token keeps 2048. Otherwise it limits the whole token's claims (default 2048). |

## Supabase advisors [#supabase-advisors]

### BS100 [#bs100]

**Security Advisor findings.** Each finding keeps the advisor's own severity
(error, warning or info), title and remediation link. The advisor covers RLS
disabled in exposed schemas, RLS without policies, security definer functions
anon or authenticated can call, mutable `search_path`, `auth.users` exposed
through views, `user_metadata` in policies and more. The message ends with the
lint name, for example `[rls_disabled_in_public]`.

Where the findings come from depends on what doctor reads:

* **Hosted projects** (`--project-ref` or `source.projectRef`): the Management
  API's `GET /v1/projects/{ref}/advisors/security`, the same data the dashboard
  shows.
* **Local stack, `$DATABASE_URL`, `$SUPABASE_DB_URL` or `--db-url-stdin`:** [splinter](https://github.com/supabase/splinter),
  the SQL behind the advisors. Doctor downloads `splinter.sql` at a pinned
  commit, checks its SHA-256, caches it in
  `node_modules/.cache/better-supabase` and runs it in a read-only transaction
  that is rolled back. splinter has no license, so it isn't bundled.
* **Saved snapshots:** there is no database to lint, so doctor reports an info
  finding saying the advisors were skipped.

If the advisors can't run (offline, no access token), doctor reports a warning
instead of failing silently. For `--project-ref`, use a scoped personal access
token limited to the project and the advisor and read-only query permissions
(see [Hosted projects](/docs/cli/gen#hosted-projects-without-a-database-password)),
not a classic token with access to your whole account.

### BS200 [#bs200]

**Performance Advisor findings.** Same sources as BS100: unindexed foreign
keys, `auth.uid()` re-evaluated per row in policies, multiple permissive
policies, unused and duplicate indexes, tables without a primary key, table
bloat and more. Doctor's own [BS207](#bs207) and [BS216](#bs216) repeat
splinter's `multiple_permissive_policies` and `unindexed_foreign_keys`; when
BS200 runs and the advisor loads, they skip the tables splinter reports, so
each problem shows up once.

## Security [#security]

### BS103 [#bs103]

**Error: policy allows anonymous writes.** A permissive `insert`, `update`,
`delete` or `all` policy for `anon` or `public` uses `true`. Anyone can change
rows. Restrict the policy to `authenticated` and check ownership.

### BS106 [#bs106]

**Error: table not granted to the Data API.** Supabase no longer grants new
tables to `anon` and `authenticated` (new projects from May 30, 2026, existing
projects from October 30, 2026). Without a grant, every request fails with
`42501` before RLS runs. Tables listed in [`expose`](/docs/guides/data-api-grants)
need exactly the privileges listed; other tables in the generated schemas need
`select` for `authenticated`, except tables with RLS on and no permissive
policy when
`sql.modules.grants.options.fromPolicies` is on: those are service-only, so
BS106 expects no grants for them ([BS115](#bs115)). Add the table to `expose`
and run `better-supabase sql add grants`. When `config.toml` sets
`[api] auto_expose_new_tables = false`, the finding says so.

A table that only the server reads, such as an audit log written with the
secret key, is kept off the Data API on purpose. Mark it
`tables: { audit_logs: { serviceRole: true } }`: BS106 then checks the
opposite, that `anon` and `authenticated` (directly or through `public`) have
no grant on it, and reports any they have. Its models are still generated, so
an admin client keeps its types. `exclude` also silences BS106, but leaves
the table out of the generated models.

### BS107 [#bs107]

**Warning: tenant table without a policy for every command.** A table with
the tenant column (or whose policies call a membership or tenant helper) has
permissive policies for `authenticated` covering some of `select`, `insert`,
`update` and `delete`, but not all. RLS denies the missing commands without
an error, so an update or delete matches 0 rows and the UI looks like it
worked. Add the missing policies, or a restrictive `using (false)` policy to
make a read-only table explicit, and cover the table with
[`expectTenantIsolation`](/docs/testing#tenant-isolation). The tenant table
itself (the one the tenant column references) is skipped.

### BS108 [#bs108]

**Error: policy reads `auth.mfa_factors` directly.** `authenticated` can't
select from `auth.mfa_factors`, so the policy fails every request with
`42501`. Use `(select better_supabase.mfa_satisfied())` from
`better-supabase sql add mfa`, a `security definer` function. See
[MFA and SSO](/docs/auth/mfa-sso).

### BS109 [#bs109]

**Warning: `auth.role()` in a policy or function.** Supabase deprecated
`auth.role()`. Doctor reads every policy, the functions policies call and the
functions in the exposed schemas. Scope the policy with `to authenticated`
instead, or read the claim with `(select auth.jwt() ->> 'role')`.

### BS110 [#bs110]

**Warning: update policy without a select policy or with check.** An update
finds its rows through the select policies first, so a role with an update
policy but no select policy updates nothing and gets no error. Add a select
policy for the same role. An update policy without `with check` is an info
finding: Postgres then checks the new row against `using`, which leaves
unstated whether a row may move to another owner or tenant. Write both:

```sql
create policy notes_update on public.notes for update to authenticated
  using (organization_id in (select better_supabase.member_organization_ids()))
  with check (organization_id in (select better_supabase.member_organization_ids()));
```

### BS111 [#bs111]

**Warning: API roles hold privileges they don't use.** `anon` and
`authenticated` never need `truncate`, `references` or `trigger` on a table,
and `truncate` skips RLS. The finding lists the `revoke`. A grant to `anon`
on a table with RLS where no policy applies to `anon` is an info finding: it
lets no request through, but lists the table in the Data API schema that
anyone with the publishable key can read.

### BS112 [#bs112]

**Warning: exposed security definer function that doesn't check the
caller.** A `security definer` function runs as its owner, past RLS. In an
exposed schema the Data API serves it at `/rpc/<name>`, so when `anon` or
`authenticated` may execute it and its body never reads `auth.uid()`,
`auth.jwt()` or the request claims, any caller gets the owner's access. Check
the caller in the body, revoke `execute` from the API roles, or move the
function to a schema the API doesn't expose. Trigger functions are skipped;
`/rpc` can't call them. Needs a snapshot taken with this version, which
records who may execute each function.

### BS113 [#bs113]

**Info: storage upload policy without the upsert policies.** An upload with
`upsert: true` (the `x-upsert` header) needs `select` and `update` policies
on `storage.objects` besides `insert`. Without them, replacing a file that
exists fails with 403. Doctor reads the policies in `supabase/schemas` and the
migrations; add the missing ones with the same bucket and path check.

### BS114 [#bs114]

**Error: policy helper reads `user_metadata`.** Users can change their own
`raw_user_meta_data` with `auth.updateUser()`, so a function that reads
`user_metadata` to decide access lets them grant themselves rights. Read
`app_metadata`, which only the service role can write. Splinter's
`rls_references_user_metadata` (BS100) covers the policy text; this check
covers the functions policies call, and the functions those call.

### BS115 [#bs115]

**Warning: table without a policy still granted to the Data API.** A table
with RLS on and no permissive policy for `anon` or `authenticated` answers
every request with no rows or a denied write, yet a grant lists it in the API
schema. With `sql.modules.grants.options.fromPolicies`, `better-supabase sql sync`
revokes `anon`, `authenticated` and `public` on every table in `schemas` with
RLS on that no `expose` entry and no permissive policy reaches, and keeps the privileges
of `service_role`. Until the migration is applied, doctor reports the grants
that are still on the database. Tables in `expose`, `tables.<name>.exclude`
and `tables.<name>.serviceRole` are skipped.

### BS116 [#bs116]

**Warning: security definer function open to the API roles but not
exposed.** Postgres grants `execute` on a new function to `public`, so a
`security definer` function in an exposed schema is callable at `/rpc` by
`anon` and `authenticated` unless it is revoked. When no
[`expose`](/docs/guides/data-api-grants) entry lists the function and no
policy calls it, nothing in the project says the API roles should reach it.
Doctor never revokes anything. Run
`revoke all on function schema.name(args) from public, anon, authenticated;`,
then grant `execute` to the roles that call it (or list the function in
`expose`). [BS112](#bs112) reports the same functions when they also skip a
caller check. Needs a snapshot taken with this version, which records who may
execute each function.

## Performance [#performance]

### BS204 [#bs204]

**Warning: tenant column without an index.** With the
[tenant plugin](/docs/plugins), every query filters on the tenant column. Add
an index that starts with it.

### BS205 [#bs205]

**Warning: policy calls a slow function once per row.** A policy passes a
column of the row, such as `is_member(organization_id)`, to a function
Postgres can't inline: `plpgsql` or another non-SQL language, `volatile`,
`security definer`, or with any `set` option (`set search_path = ''`
included). The call runs once for every row the query reads. Doctor finds the
functions a policy calls in `pg_depend`, so helpers in private schemas count
too. Have the helper return the allowed values once and compare, which works
for security definer helpers as well:

```sql
create policy notes_member on public.notes for select to authenticated
  using (organization_id in (select private.user_organization_ids()));
```

### BS206 [#bs206]

**Warning: security definer policy helper that cannot be inlined.** A
`security definer` function appears in more than `doctor.policyHelperLimit`
policies (default 5). Postgres never inlines a security definer function, even
one written in `language sql stable`, so a call that takes a column of the row
runs once per row. Call it as `(select helper(...))` when its arguments don't
come from the row, so Postgres evaluates it once per statement as an
InitPlan, or have it return the allowed ids as a set and compare with
`organization_id in (select helper())`.

### BS207 [#bs207]

**Warning: several permissive policies for one command and role.** Postgres
evaluates every permissive policy that applies to a command and role, and ORs
the results, so each extra policy adds its cost to every row. The message
lists each command and role with its policies; a policy for `public` counts
for every role. Merge each group into one policy with `or`. When
[BS200](#bs200) runs, splinter's `multiple_permissive_policies` reports the
tables it covers and this check skips them; on a saved snapshot, or when the
advisor can't run, this check reports every table.

### BS208 [#bs208]

**Warning: queries spill to temporary files.** Reads `temp_files` and
`temp_bytes` from `pg_stat_database` since the statistics were last reset,
with the current `work_mem`. When `pg_stat_statements` is installed, the
message lists the five statements that wrote the most temporary blocks. Add
indexes so large sorts go away, or raise `work_mem` for the role that runs
them. Needs a database; saved snapshots skip it.

### BS209 [#bs209]

**Warning: slow frequent statements.** With `--stats`, doctor reads
`pg_stat_statements` and reports statements with a mean time above 50 ms and
more than 1000 calls, slowest total first. When a statement names a table in
the exposed schemas, the finding points at the file that creates the table.
Without the extension, doctor reports an info finding with the
`create extension` command.

### BS210 [#bs210]

**Warning: aggregates used while PostgREST disables them.** PostgREST
answers `aggregate()`, `_sum`, `_avg`, `_min` and `_max` includes and list
[`facetCounts`](/docs/platform/list#facet-counts) with PGRST123 unless `pgrst.db_aggregates_enabled` is on for the `authenticator`
role. Doctor reads the role's settings from the database and scans the files
in `doctor.sources` (default `src/**/*.{ts,tsx}`) for those calls. Turn
aggregates on in a migration:

```sql
alter role authenticator set pgrst.db_aggregates_enabled = 'true';
notify pgrst, 'reload config';
```

Saved snapshots from before this check carry no role settings, so they skip it.

### BS211 [#bs211]

**Info: statement timeouts for the Data API roles.** PostgREST switches to
`anon` or `authenticated` for each request, so their `statement_timeout`
limits Data API queries; doctor reports the values set on `anon`,
`authenticated` and `authenticator`. A function's own
`set statement_timeout = ...` only applies to an RPC when PostgREST hoists
it into the transaction. When `pgrst.db_hoisted_tx_settings` is set without
`statement_timeout`, doctor warns for each exposed function that sets one.
It also reports each role's `idle_in_transaction_session_timeout`, which ends
a session that leaves a transaction open. A role setting applies to the role
a connection logs in as, not to `set role`, so for `better-supabase/postgres`
set it on the login role or with the pool's `idleInTransactionTimeout`.

### BS212 [#bs212]

**Info: RLS plan for a table.** `--explain customers,notes` runs, for each
table, in a transaction that is rolled back:

```sql
set local track_functions = 'all';  -- skipped if the role may not set it
select set_config('request.jwt.claims', '<claims>', true);
set local role authenticated;       -- the claims' role, default anon
explain (analyze, buffers, format json) select * from public.customers limit 1000;
```

That is the query `findMany()` sends, capped at Supabase's default `max_rows`.
`--as <uuid>` plans as `{ "sub": uuid, "role": "authenticated" }`;
`--claims '{"role":"authenticated","tenant_id":"..."}'` sets any claims your
policies read. Qualify tables outside the exposed schemas, such as
`better_supabase.memberships`.

The finding lists node types, timings and loops, never row data, with the
number of InitPlans and each function's share of the execution time from
`pg_stat_xact_user_functions`. A SubPlan that runs more than once means a
policy is evaluated per row, and the finding becomes a warning: wrap the
policy's function calls in `(select ...)` so they run once as an InitPlan.
`--explain` needs a direct connection (local stack, `$DATABASE_URL`, `$SUPABASE_DB_URL` or `--db-url-stdin`); the
Management API's read-only endpoint can't switch roles.

```text
info    BS212 RLS plan for a table
        public.customers as authenticated 0000…00ff: 0.15 ms, 1 InitPlan.
        Plan: Limit 0.14 ms ×1 > InitPlan 1: Result 0.13 ms ×1 > Seq Scan on
        customers 0.14 ms ×1. Function time: better_supabase.current_tenant_id
        0.13 ms over 1 call (83%).
```

### BS213 [#bs213]

**Warning: API roles can write the columns that grant access.** RLS helpers
decide who sees a row from other rows: `better_supabase.has_organization_role` reads
`memberships.user_id` and `memberships.role`, and a contact-scoped helper reads
`contacts.user_id` and `contacts.customer_id`. Doctor collects the columns
that the helpers your policies call read, including the helpers those call,
plus the `decidingColumns` of the authorization provider. It warns when `anon` or `authenticated` may insert or update one of
those columns and a policy lets them write the row: a member who can update
their own `role`, or a portal contact who can change `customer_id`, grants
themselves access.

A table-level grant counts even after a column-level revoke, because
Postgres checks the table grant first. A grant to `public` counts for both
roles, and the SQL then revokes it from `public` too. The finding lists the SQL that keeps
the other columns writable:

```sql
revoke insert, update on public.contacts from authenticated;
grant insert (id, name, email), update (id, name, email) on public.contacts to authenticated;
```

Helper bodies come from the database, or from your SQL files when the
snapshot doesn't have them. Column grants need a snapshot taken with this
version; older snapshots only show table grants.

### BS214 [#bs214]

**Error: permission the provider's SQL functions don't fully answer in a
Storage or Realtime policy.** An access policy on a bucket or topic reaches
the [authorization provider's](/docs/extending/authorization-providers)
functions through its `sql` templates, or through the access contract under
the `provider` access model. Those functions decide by role and scope, so a
permission with other conditions (such as `ownerId = principal.id`) would grant
every object or topic in the scope. Doctor reports every key whose
`authorization.permissions` entry isn't `sqlComplete: true`: a key marked
`false`, a key without the flag, a key the provider doesn't list, and every
key when the provider lists no permissions. `better-supabase gen` refuses to
write those bucket policies.

Doctor checks the `buckets` in the config and the `bs_` policies on
`storage.objects` and `realtime.messages` in your SQL files, matching the
provider's `idsWith` and `isPlatform` templates. It also reports a scope the
provider doesn't declare, and a key checked at a scope its entry's `scopes`
doesn't list.

It warns when a bucket's `sql` templates differ from the provider's `idsWith`
and `isPlatform`, because a copy goes stale when the provider changes. Set
`sql: "provider"` so `better-supabase gen` writes the provider's templates.

### BS215 [#bs215]

**Warning: policy calls a helper per row that could run once.** A call such
as `current_tenant_id()` takes no column of the row, so its result is the same
for every row. Written bare, Postgres may run it for each row; wrapped as
`(select current_tenant_id())`, it runs once per statement as an InitPlan.
Splinter's `auth_rls_initplan` (BS200) covers `auth.uid()` and `auth.jwt()`;
this check covers your own helpers. Immutable functions are skipped.

### BS216 [#bs216]

**Info: foreign key without an index.** Deleting or updating a referenced row
scans the referencing table for each row, and joins on the key can't use an
index. Add an index whose leading columns are the key's columns, in any order.
When [BS200](#bs200) runs, splinter's `unindexed_foreign_keys` reports the
tables it covers and this check skips them; on a saved snapshot, or when the
advisor can't run, this check reports every table.

### BS217 [#bs217]

**Warning: tenant foreign key that can cross tenants.** With the
[tenant plugin](/docs/plugins), a tenant table that references another tenant
table by `id` alone can point at a parent in another organization: RLS checks
each row, not the pair. Reference the tenant column as well, backed by a
unique key on the parent:

```sql
alter table public.customers add unique (id, organization_id);
alter table public.notes add foreign key (customer_id, organization_id)
  references public.customers (id, organization_id) on delete cascade;
```

### BS218 [#bs218]

**Info: soft-delete table without a partial index.** A table with a nullable
`deleted_at` or `archived_at` column is mostly read for its live rows. A
partial index with `where deleted_at is null` stays small and matches those
queries:

```sql
create index customers_active_idx on public.customers (organization_id, created_at desc)
  where archived_at is null;
```

Snapshots from before index predicates were recorded accept any partial
index.

### BS219 [#bs219]

**Warning: containment filter on a column without a GIN index.** `@>`, `<@`,
`?`, `?|`, `?&` and `&&` on `jsonb` and array columns, and the
`.contains()`, `.containedBy()` and `.overlaps()` filters, can only use a GIN
index; without one, every query reads the table. Doctor looks in the
policies, the functions in the exposed schemas and the files in
`doctor.sources`. Use `jsonb_path_ops` when you only filter with `@>`, which
makes the index smaller; leave it out for `?`:

```sql
create index notes_meta_idx on public.notes using gin (meta jsonb_path_ops);
```

### BS220 [#bs220]

**Info: column type to avoid.** `timestamp` drops the time zone, `varchar(n)`
and `char(n)` only add a length limit that a check constraint on `text`
states as well, `money` rounds by locale, and `json` is parsed again on every
read. Use `timestamptz`, `text`, `numeric` and `jsonb`.

### BS221 [#bs221]

**Warning: direct database connection in a serverless app.** Each serverless
or edge invocation opens its own connection, so through the direct host
(`db.<ref>.supabase.co`, which is also IPv6 only) or the session pooler on
port 5432 they run out of `max_connections`. Use the transaction pooler on
port 6543 (`postgres://postgres.<ref>:<password>@<region>.pooler.supabase.com:6543/postgres`).
Doctor checks the env files when the sources export route handlers, a
`runtime`, a `fetch` handler or `Deno.serve`, or an env file sets `VERCEL`
variables. The message names the variable, never its value.

### BS222 [#bs222]

**Warning: column that can exceed a JavaScript number.** `int8` and `numeric`
decode as `number` by default, which matches `supabase gen types`, but a
`number` loses precision past 2^53 (about 15 to 17 significant digits). Doctor
reports `int8` identity and sequence columns, which keep growing toward that
limit, and `numeric` columns, which usually hold exact amounts. Set
`codecs.int8` to `"bigint"` or `"string"`, or `codecs.numeric` to `"string"`,
in `better-supabase.config.ts` ([codecs](/docs/cli/config#options)), or add BS222
to `doctor.ignore` when the values stay small.

## Drift [#drift]

### BS301 [#bs301]

**Warning: soft delete hidden by a select policy.** After an update, PostgREST
reads the row back through the select policy. If that policy hides rows where
the soft-delete column is set, `softDelete()` fails with an RLS error. Filter
deleted rows in queries instead; the soft-delete plugin already does.

### BS302 [#bs302]

**Warning: bucket differs from the config.** A bucket in `buckets` is missing
from the database, or its `public`, file size limit or MIME types differ.
When the Storage version has the columns, `versioning` and `lifecycle` are
compared too (generated lifecycle rule ids are ignored). Update the config,
or run `bucket.apply(client)` to set the bucket through the Storage API.

### BS303 [#bs303]

**Error: generated code is out of date.** The generated module doesn't match
the database, so types and metadata are wrong. Run `better-supabase gen`. This
is the same check as `gen --check`.

### BS304 [#bs304]

**Warning: SQL module files are out of date.** A module in `sql.modules` differs from
the version this release ships. Doctor renders the files with the same layout
as `sql sync` (grants from policies, session policies, audited tables and the
vector schema), so the two agree on what is out of date. Run `better-supabase sql sync`, then
`supabase db schema declarative sync` (`supabase db diff` on migra) to create
a migration.

For the `read-sets` module, doctor imports the modules in `readSets` and
compares the functions they compile to. If a module can't be imported,
doctor skips that file instead of reporting it.

### BS305 [#bs305]

**Warning: live query table without change broadcasts.** A table in
`realtime.tables` has no `bs_realtime` trigger, so
[live queries](/docs/frontend/live-queries) never hear about its changes. Run
`better-supabase sql add realtime-tables`, then
`supabase db schema declarative sync`.

### BS306 [#bs306]

**Warning: Realtime delete events without keys.** A table in the
`supabase_realtime` publication has replica identity `nothing`, or `default`
without a primary key, so `postgres_changes` delete events carry no keys. Add a
primary key, or run `alter table ... replica identity full`.

### BS307 [#bs307]

**Error: custom SQL module without its contract.** A module in `sql.modules` uses
`mode: 'custom'`, so the app writes the functions other modules and the
TypeScript APIs call. With a database connection, doctor compares each
function's argument and return types with the contract; without one, it looks
for a `create function` in `supabase/schemas` and the migrations. Write the
function, or switch the module to `adopt` or `managed`. `better-supabase sql
print <module>` lists the signatures.

### BS308 [#bs308]

**Warning: tenant claim the hook does not write.** With the `tenant` module and
`sql.modules.access.activeTenant: 'claim'`, `current_tenant_id()` and the
`tenant()` plugin read the active tenant from the `claims.tenant` claim, at the
top level or in `app_metadata`. With `--as <user id>`, doctor calls the custom
access token hook for that user and warns when neither is in the claims it
returns. Write the claim in the hook, or, for apps that pick the tenant from
the URL or a profile, use the default `'resolver'` (with
`ServerOptions.tenant`) or `{ profileColumn }`.

### BS309 [#bs309]

**Warning: deprecated SQL module symbol.** A file in `supabase/schemas` or a
policy uses a function, table, column or claim that a SQL module in `sql.modules`
deprecated or removed, such as `better_supabase.current_org_id()` or the
`org_id` claim. A deprecated symbol keeps a compatibility wrapper for at least
one minor release; a removed one fails at run time. The message names the
replacement. A renamed column such as `memberships.org_id` also counts
unqualified (`m.org_id`) in a statement that names its table. Block files and
migrations are skipped, and so is a claim the config maps in `claims`. Tables
and columns of a module in adopt or custom mode are the app's names and are
never reported.

### BS310 [#bs310]

**Warning: duplicate block trigger.** A table has `bs_updated_at` and another
trigger whose function looks like `updated_at`, `moddatetime` or `touch`, or
`bs_audit` and another audit trigger, so both run on every write. Drop the
older trigger, or call `track_updated_at()` or `audit()` with
`replace_trigger => true` in a migration.

### BS311 [#bs311]

**Warning: SQL module behind its current version.** A module file's
`@bs-module` line, or its row in `better_supabase.modules` on a live database,
records an older version than this release ships. Run `better-supabase sql
upgrade`, which writes the forward steps into a migration and rewrites the
files, then create the schema migration. BS304 skips files this check reports.

### BS312 [#bs312]

**Error: block schema exposed through the Data API.** `[api] schemas` in
`supabase/config.toml` lists `better_supabase` or a schema a module in `sql.modules`
sets. Block schemas hold internal tables and helpers that `authenticated` can
execute so policies can call them; exposing the schema makes them callable over
REST and RPC as well. Remove the schema from `[api] schemas` and from the
exposed schemas in the dashboard, and set `sql.modules.<module>.api` to an
exposed schema such as `api`: `sql add` writes `security invoker` entry points
there for the module's client functions, and `rpcTransport(supabase, { schema:
"api" })` calls them. Doctor reads `[api] schemas` for every exposure check and falls
back to `schemas` in `better-supabase.config.ts` without a `config.toml`.

### BS313 [#bs313]

**Warning: rate limits not wired to PostgREST.** The `rate-limit` module is in
`sql.modules`, but on the live database `pgrst.db_pre_request` for the
`authenticator` role is unset, or names a function whose body doesn't call
`better_supabase.check_request()`, so Data API writes are never counted. Apply
the migration `better-supabase sql data` writes, which sets the hook when no
other one is set, or call `check_request()` from your own pre-request
function. Doctor needs a database connection for this check. With
`sql.modules["rate-limit"].options.preRequest` set to `false`, the app calls
`check_request()` itself and BS313 does not run.

### BS314 [#bs314]

**Warning: migration-only block option.** A module in `sql.modules` sets an option
that only exists to match a schema you are adopting: `tokenStorage: "plain"`
for invitations, `secretStorage: "column"` or an `eventIdType` or `runIdType`
other than `text` for webhooks-out, a `blockSource` or `defaultSource` for
the outbox, or `values` for the audit log. The config only accepts them with
`mode: "adopt"`. Move the data to the managed default (hash the tokens, move
the secrets into Vault), then remove the option.

### BS315 [#bs315]

**Warning: table without an audit trigger.** The `audit` module is in
`sql.modules`, and a table in `schemas` has no `bs_audit` trigger, nor another
trigger that calls `better_supabase.audit_row_change()`, so its inserts,
updates and deletes are not in the audit log. Register it with
`select better_supabase.audit('public.customers')` in a schema file. Doctor
skips the block schemas and the tables SQL modules adopt; list other tables in
`sql.modules.audit.options.exempt` as `schema.table` globs, where `*` matches any run
of characters:

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      audit: { options: { exempt: ["public.*_archive", "public.sessions"] } },
    },
  },
});
```

### BS316 [#bs316]

**Warning: legacy migra diff engine.** The project keeps declarative schemas
in `supabase/schemas` or lists `sql.modules`, but `supabase/config.toml` has
no `[experimental.pgdelta] enabled = true`, so the Supabase CLI diffs the
schema with migra. Migra drops grants, comments and `security_invoker` on
views, and runs only while the stack is stopped. Projects from `supabase init`
and `better-supabase init` use pg-delta. To switch, add the table, remove
`[db.migrations] schema_paths` (pg-delta orders files by dependency), and
create migrations with `supabase db schema declarative sync` instead of
`supabase db diff`:

```toml title="supabase/config.toml"
[experimental.pgdelta]
enabled = true
```

### BS317 [#bs317]

**Warning: bulk grant in a pg-delta schema file.** Under pg-delta, a
`grant ... on all tables in schema`, `on all routines in schema` or `on all
sequences in schema` statement in `supabase/schemas` that reaches `anon`,
`authenticated` or `public` has no dependency position. pg-delta can run it after the per-object revokes that narrow a
function or table, which re-opens them to `anon` and `authenticated`. Grant
each object by name next to its definition:

```sql
revoke execute on function public.recalculate_totals(uuid) from public, anon;
grant execute on function public.recalculate_totals(uuid) to authenticated;
```

For objects created later, use Supabase default privileges instead of a bulk
grant: `alter default privileges in schema public grant select on tables to
authenticated;`. The [`grants` module](/docs/guides/data-api-grants) writes
per-table grants from `expose`.

### BS318 [#bs318]

**Warning: catalog loop in a pg-delta schema file.** A `do` block in
`supabase/schemas` that reads `information_schema` or `pg_catalog` (`pg_class`,
`pg_tables`, ...) and executes statements runs, under pg-delta, before the
tables it looks for exist. It creates nothing, and the diff never sees the
triggers, grants or policies it was meant to add. Write the statements as
static SQL, one per table, or call a per-table function once per table, such
as `select better_supabase.audit('public.invoices');` or
`select better_supabase.track_updated_at('public.invoices');`.

### BS319 [#bs319]

**Error: SQL alters a reserved role.** A schema file, SQL module or migration
alters, drops or changes the memberships of a role that supautils reserves on
Supabase: `supabase_admin`, `supabase_auth_admin`, `supabase_storage_admin`,
`supabase_functions_admin`, `supabase_read_only_user`,
`supabase_realtime_admin`, `supabase_replication_admin`, `supabase_etl_admin`,
`dashboard_user` and `pgbouncer`. The statement works on a local superuser
connection and fails on a hosted project. `authenticator`, `authenticated`,
`anon` and `service_role` accept `alter role ... set` and `reset`, such as
`alter role authenticator set pgrst.db_pre_request = '...'`, and nothing
else. Granting privileges on objects to a reserved role
(`grant usage on schema auth to supabase_auth_admin`) is not flagged. Create
your own role for anything else.

### BS320 [#bs320]

**Warning: table without the session policy.** The `sessions` module is in
`sql.modules`, and a table with RLS in `schemas` has no restrictive policy
that calls `better_supabase.session_active()`, so a token whose session was
signed out, or whose user was banned or deleted, keeps reaching the table
until it expires. Set `sql.modules.sessions.options.policies` to `true` and
run `better-supabase sql sync` to write the policy on every table, or add it
by hand. Doctor skips the block schemas and the tables SQL modules adopt; list
other tables in `sql.modules.sessions.options.exclude` as `schema.table`
globs. See [Ended sessions](/docs/auth/account-deletion#ended-sessions).

### BS321 [#bs321]

**Warning: extension missing from the migrations under pg-delta.** A
`create extension` in `supabase/schemas`, a SQL module's file included, that
no migration in `supabase/migrations` repeats. pg-delta can leave an
extension that owns its own schema, such as `pgmq`, out of the generated
migration, so a database built from the migrations (a branch, CI or
production) lacks it. For a module that owns such an extension, run
`better-supabase sql sync`: it writes the extension into a migration that
runs before the schema migration. For your own schema files, add
`create extension if not exists <name>;` to a migration that runs before the
one that uses it. The rule stays quiet until the project has a migration.

### BS322 [#bs322]

**Warning: audit registration of a dropped table.** The `audit` module is in
`sql.modules`, and `better_supabase.audited_tables` on the live database has
a row whose table no longer exists. Since the module installs the
`bs_audit_forget_dropped` event trigger, dropping a table deletes its row,
but a table dropped before that keeps it. Run `better-supabase sql sync`
and apply the migration `better-supabase sql data` writes: the module's data
file deletes registrations whose table is gone. Doctor checks this only
against a database.

### BS323 [#bs323]

**Warning: module event trigger missing.** A module in `sql.modules`
creates an event trigger (`bs_audit_forget_dropped` for `audit`,
`bs_ensure_rls` for `ensure-rls`) that the live database lacks, or, without
a database, that no migration in `supabase/migrations` creates. Event
triggers belong to no schema, so a schema diff limited to some schemas
(`supabase db schema declarative sync -s ...`) leaves them out of the
migration, and dropped tables keep their audit registrations or new tables
get no RLS. Run `better-supabase sql sync`, then `better-supabase sql data`:
the module's data file repeats its event triggers, so the data migration
creates them. Run the declarative sync without `-s` from then on.

### BS324 [#bs324]

**Error: shared roles table without a role condition.** Under the
`provider` model, the tenant module's `roleThrough` and the invitations
module's `platformRoles.through` name the same roles table, and one of them
has no `where`. That side then resolves a role id or key of the other kind:
an organization invitation, its accept or `update_member_role` can grant a
platform role, or a platform invitation a tenant role. `better-supabase sql
sync` refuses the config. Set `where` on both, a condition on the roles row
`{row}` such as `{row}.scope = 'organization'` and `{row}.scope = 'system'`.
A `roleThrough` from the provider's `roleSources` has no `where`, so set
`sql.modules.tenant.options.roleThrough` in the config.

### BS325 [#bs325]

**Warning: audit log readable only as the service role.** The `audit` module
defaults `eventRoles` to `service_role` and `readPolicy` to false, so
`audit.list()` under a user session is 403. Set `options.readPolicy: true`
and include `authenticated` in `options.eventRoles` when the app lists the
log as the signed-in user.

### BS326 [#bs326]

**Warning: admin Auth call without a secret key in env files.**
`deleteAccount` and `bs.admin()` need `SUPABASE_SECRET_KEY`. Add it to
`.env.local` (and the hosted environments), listed without a value in
`.env.example`.

### BS327 [#bs327]

**Warning: aal2 required without MFA enabled.** The app calls
`requireAal('aal2')`, but `[auth.mfa.totp]` in `config.toml` does not set
`enroll_enabled` and `verify_enabled` to true, so no user can satisfy the
check.

### BS328 [#bs328]

**Warning: `rpc()` on a Supabase Lite SQLite driver.** `[db] driver` in
`config.toml` is `sqlite-postgres` or `sqlite`, and a file in
`doctor.sources` calls `$rpc`, `.rpc()` or `rpcTransport`. Lite has no
`rpc()` on SQLite, so the server returns an `unsupported` error without
sending the request. Switch `[db] driver` to `pglite` or `postgres`, or
replace the function with table queries. See
[Supabase Lite](/docs/platform/lite).

### BS329 [#bs329]

**Warning: feature Supabase Lite does not implement.** `config.toml` sets
`[db] driver`, so the project runs on Supabase Lite, and a file in
`doctor.sources` uses Realtime (`better-supabase/realtime` or `.channel()`)
or Edge Functions (`functions.invoke()`). Lite implements neither on any
driver. Run the full Supabase stack for that feature, or move the project
with `supabase lite upgrade`.

## Auth config [#auth-config]

### BS401 [#bs401]

**Warning: refresh token reuse interval is 0.** With refresh token rotation,
server instances that refresh the same session at the same time need a reuse
interval, or all but one of them sign the user out. Use `10`, the Supabase
default.

### BS402 [#bs402]

**Info: long access token lifetime.** better-supabase verifies tokens locally
instead of calling the auth server, so a revoked session stays valid until its
token expires. Keep `jwt_expiry` at 3600 or less; the proxy refreshes sessions
for you.

### BS403 [#bs403]

**Info: local stack signs tokens with a shared secret.** Hosted projects sign
with asymmetric keys. Run [`better-supabase keys`](/docs/cli/local#keys) so
local tokens verify through JWKS, the same as in production.

### BS404 [#bs404]

**Error: Auth hook function grants.** For every enabled
`[auth.hook.<name>]` in `supabase/config.toml` with a
`pg-functions://postgres/<schema>/<function>` URI, doctor introspects the
function, even in a schema outside `schemas`. Auth calls it as
`supabase_auth_admin`, which needs `usage` on the schema and `execute` on the
function. Nobody else should be able to call it: through the Data API, a
client could call the custom access token hook for any user id and read the
claims it adds. The finding lists the SQL that fixes it:

```sql
grant usage on schema rbac to supabase_auth_admin;
grant execute on function rbac.custom_access_token_hook(event jsonb) to supabase_auth_admin;
revoke execute on function rbac.custom_access_token_hook(event jsonb) from authenticated, anon, public;
```

Add the statements to the schema file that defines the function and run
`supabase db schema declarative sync`: pg-delta carries function grants into
the migration. `better-supabase doctor --fix-grants` prints the block for
every hook at once, ready to paste into that file.

On the legacy migra engine ([BS316](#bs316)), `supabase db diff` drops
function grants, so append the block to the migration it wrote:

```bash
supabase db diff -f auth_hook
better-supabase doctor --fix-grants >> supabase/migrations/<timestamp>_auth_hook.sql
```

When the function is the authorization provider's hook
(`authorization.tokenHook.function`, or a file with its `markers.hook` line),
the finding lets the provider write the grants instead, with its
`grantsCommand` when it sets one.

When a file already grants the function, the database is behind, and the
finding names that file. On migra only a migration with the provider's
`markers.grants` line counts, and the finding says to run
`supabase migration up`. With pg-delta the declarative schema file that
defines the hook counts too, and the finding says to run
`supabase db schema declarative sync`, then `supabase migration up`.

Doctor reads the declarative schemas, then the migrations newest first, and
points findings at the first file that declares the object. With pg-delta it
reads every file in `declarative_schema_path` (default `supabase/schemas`) in
name order. On migra it follows `[db.migrations] schema_paths`, then the files
no entry matches.

A hook whose function doesn't exist is reported at its `config.toml` line,
because every sign-in fails until it does. Snapshots taken before the hook
was configured skip it; run `better-supabase introspect` again.

### BS405 [#bs405]

**Warning: custom access token hook shape.** Auth runs the hook on every
sign-in and token refresh. Declare it `stable` and give it
`set search_path = ''`, qualifying every table it reads.

The claims it returns travel with every request, in the session cookie and in
the `Authorization` header. With `--as <uuid>`, doctor calls the hook for that
user the way Auth does, as `supabase_auth_admin` with the user's standard
claims, in a transaction that is rolled back. Two limits apply:

| Limit                      | Measures                                            | Default                                                   |
| -------------------------- | --------------------------------------------------- | --------------------------------------------------------- |
| Whole token                | `octet_length` of all the claims                    | 2048 bytes, or `doctor.claimsLimit` without a hook budget |
| The provider's hook budget | `octet_length` of each of `tokenHook.budget.claims` | `tokenHook.budget.bytes`, or `doctor.claimsLimit`         |

Each one is a separate warning, so a token of 1.5 KB whose budget claims fit
the budget passes. When the hook sets `tokenHook.budget.truncatedClaim`,
doctor also reports that the token lists only some entries, so server checks
for that user need a database lookup. Keep ids and roles in the token and
look everything else up. This needs a direct connection (local stack,
`$DATABASE_URL`, `$SUPABASE_DB_URL` or `--db-url-stdin`).

### BS406 [#bs406]

**Warning: foreign key to `auth.users` blocks account deletion.** A key with
`no action` or `restrict` makes `auth.admin.deleteUser`, and
[`deleteAccount`](/docs/auth/account-deletion), fail with "Database error
deleting user" while the user still has rows. Use `on delete cascade` for data
the user owns (profiles, memberships, notifications) and `on delete set null`
for records that outlive them (`created_by` on shared documents, audit rows).

### BS407 [#bs407]

**Error: two authorization hooks.** The authorization provider's hook
(`authorization.tokenHook`) owns the claims in `ownedClaims`, plus its
`tenantClaim`. A custom access token hook that also calls
`better_supabase.membership_claims` (when `memberships` is owned), or writes
one of those claims itself through `jsonb_set` or `jsonb_build_object`, gives
them two sources that drift apart. Use the provider's hook and remove the
extra writes.

The provider's own hook is not reported: doctor recognizes it by
`tokenHook.function` or by its `markers.hook` line in the file that creates
it. Claims in `registeredClaims`, such as `features` from
`better_supabase.feature_claims`, are sources of the provider's hook and are
not a second writer. A hook that wraps the provider's hook and then writes one
of those claims again is reported, with the function that already fills it.

### BS408 [#bs408]

**Warning: the entitlements module can't read the provider's memberships.**
With `entitlements.memberships: "provider"` (the default when the config has
`authorization`), the [`entitlements` block
module](/docs/blocks/entitlements) reads memberships through the provider's
`memberIds` template in `has_entitlement`, which runs as `authenticated`, and
`memberIdsFor` in `feature_claims`, which the provider's hook calls as
`supabase_auth_admin`. When `entitlements` is in `sql.modules`, doctor warns
when:

* the provider can't back the mode: `tenantScope` is not one of its scopes,
  the scope has no `idType` or one other than `uuid`, `text`, `bigint` or
  `integer`, or `memberIds` or `memberIdsFor` is missing. `sql add
  entitlements` refuses to render the module then; set
  `entitlements.memberships: "tenant"` to use the tenant module's memberships;
* the provider's hook doesn't fill `claims.features` from
  `better_supabase.feature_claims` (`tokenHook.registeredClaims`), so
  `hasEntitlement()` would never see the plan features. With
  `entitlements.claim: false` the token carries no features, so doctor skips
  this check;
* no `authorization.memberships` table covers the tenant scope, so
  `entitlement_members()` finds no users to sign out after a plan change (an
  info finding when the provider lists no memberships at all);
* a function those templates call is missing from `authorization.requires`,
  isn't executable by the role that calls it, or isn't in the database.

### BS409 [#bs409]

**Warning: the authorization provider and the better-supabase config
disagree.** The provider's hook writes the active tenant to
`tokenHook.tenantClaim`. Doctor warns when `claims.tenant` names another
claim, because the tenant plugin, the guards and the module's RLS would then
read a claim the hook never writes. It notes when `claims.scope` is neither
`tenant` nor the provider's `tenantScope`, notes when the provider sets
`suspension` or `roleSources` while `sql.modules.access.model` is not
`provider` (only that model reads them), and reports every entry of the
provider's `problems`, such as a file version it can't read.

### BS410 [#bs410]

**Warning: HTTP auth hooks.** Auth calls an `[auth.hook.<hook>]` with an
`http://` or `https://` URI by sending a request signed with the Standard
Webhooks secret in `secrets` (`v1,whsec_<base64>`, several joined with `|`).
Doctor can't read the endpoint's code, so it reports each HTTP hook as an info
finding; verify the request there with `authHook` from
[`better-supabase/blocks/webhooks`](/docs/standards/webhooks). It also reports:

* an error when `secrets` is missing, or when a secret is not
  `v1,whsec_<base64>` or decodes to fewer than 24 or more than 64 bytes. The
  message never quotes the secret;
* a warning when the secret is written in `config.toml` instead of read with
  `secrets = "env(AUTH_HOOK_SECRET)"`. A value read from `env()` is checked
  only when `@supabase/config` interpolated it;
* a warning when a non-local endpoint uses plain `http`.

### BS411 [#bs411]

**Error: the provider access model can't use the authorization provider.**
With `sql.modules.access.model: "provider"`, `can()` and the SQL modules fill
the provider's `idsWith` template for tenant checks and `isPlatform` for
platform checks, at its `tenantScope`. Doctor reports, and `sql add` refuses:

* a config without `authorization`;
* a `tenantScope` the provider doesn't declare, a scope `idType` other than
  `uuid`, `text`, `bigint` or `integer`, or a `sql.modules.access.idType`
  that differs from it;
* a function those templates call that `authorization.requires` doesn't list,
  doesn't let `authenticated` execute, or the database lacks;
* a permission key a SQL module checks (`modulePermissionKeys` from
  `better-supabase/sql` lists them) that the provider doesn't mark
  `sqlComplete: true`. Its functions decide by role and scope only, so a key
  with other conditions would be granted everywhere in the scope. Map the
  action to another key in `sql.modules.<module>.permissions`.

It warns when neither `sql.modules.access.functions.canAssign` nor the
provider's `canAssign` is set, because only the service role then assigns
roles. See [the access block](/docs/blocks/access) for the recipe.

It also warns for each optional template an installed module calls that the
provider doesn't set, and names the modules:

| Template        | Modules                                                                                        | Without it                                                                |
| --------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `idsWithFor`    | `invitations`, `inbox`, `comments`, `sso`, `notifications`, `connectors`, `workflow-sdk-world` | checks of another user's tenant permissions raise `0A000` or are skipped  |
| `isPlatformFor` | `invitations`                                                                                  | checks of another user's platform permissions answer false or are skipped |
| `canAssignFor`  | `invitations`                                                                                  | the inviter's right to assign the role is not checked again at acceptance |
| `memberIds`     | `usage`                                                                                        | membership checks read the tenant module's `memberships` table instead    |

`sql.modules.access.functions.canAssignFor` silences the `canAssignFor`
warning.

### BS412 [#bs412]

**Info: the session cookie encoding differs between server and browser.**
`@supabase/ssr` needs the same `cookies.encode` on both sides. Doctor reads
the files in `doctor.sources`: a file that imports `better-supabase/client` or
calls `createBrowserClient` is the browser side, and a file that imports
`better-supabase/server`, `next`, `ssr`, `hono`, `orpc`, `edge` or `expo`, or
calls `createServerClient`, is the server side. When a file on one side sets
`encode: 'tokens-only'` and a file on the other side doesn't, the other side
uses the default `user-and-tokens`: it writes the user object back into the
cookie, or reads a session without one and `session.user` throws. Set the same
`encode` in `createClient` and on the server. See
[Encoding](/docs/auth/sessions#encoding).

## Env files [#env-files]

### BS501 [#bs501]

**Error: secret in a browser variable.** A variable with a public prefix
(`NEXT_PUBLIC_`, `VITE_`, `EXPO_PUBLIC_`, `PUBLIC_`, `NUXT_PUBLIC_`) holds a
secret key. Bundlers inline these into client code. Rename the variable and
rotate the key.

### BS502 [#bs502]

**Warning: env file with secrets isn't ignored by git.** An env file holds a
secret key or database URL but no `.gitignore` pattern matches it. Add the
file, for example `.env*.local`, to `.gitignore`. `.example` files are skipped.

## Dependencies [#dependencies]

### BS601 [#bs601]

**Warning: peer outside its range.** An optional peer of better-supabase is
installed at a version outside the range better-supabase declares, for
example `@supabase/postgrest-typegen@0.5.0` against `>=0.4.0 <0.5`. With
`strictPeerDependencies` the install fails, and the subpath that needs the
peer can break at runtime. Doctor reads the version from the nearest
`node_modules` above the project root and skips peers that aren't installed.
Install a version in the range, or remove the package if nothing imports it.
[Peers](/docs/getting-started/peers) lists which subpath needs which peer.

# Errors

> What each CLI error code means, its exit code, and how to fix it.

Source: https://bettersupabase.com/docs/cli/errors

Every error the CLI reports has a code. In a terminal it prints the message
on stderr. With `--json` it prints one
[Problem Details](https://www.rfc-editor.org/rfc/rfc9457) document on
stdout instead, whose `type` links to the code's section on this page:

```json
{
  "type": "https://bettersupabase.com/docs/cli/errors#unknown_command",
  "title": "Unknown command",
  "detail": "Unknown command \"genn\". Did you mean \"gen\"?",
  "code": "unknown_command",
  "exitCode": 2,
  "suggestion": "gen"
}
```

Exit code 2 means the command line, the config or the environment needs a
change; running the same command again fails the same way. Exit code 1 means
the command ran and failed, or found something (`--check` drift, doctor
errors).

## `usage` [#usage]

An option is missing, unknown or has the wrong value. The message names the
option, and the text output adds the command's usage. Exit code 2.

## `unknown_command` [#unknown_command]

The first argument isn't a command. When one is close enough to be a typo,
`suggestion` names it. Run `better-supabase --help` for the list. Exit
code 2.

## `missing_value` [#missing_value]

The command needs a value it didn't get, such as `SUPABASE_ACCESS_TOKEN` for
`--project-ref`, or a connection string on stdin for `--db-url-stdin`.
`flag` names the variable or option that supplies it. Exit code 2.

## `config_not_found` [#config_not_found]

`--config` points at a file that doesn't exist. The path is relative to
`--cwd`. Exit code 2.

## `config_invalid` [#config_invalid]

`better-supabase.config.*` failed to load, or has a key or value the CLI
doesn't accept. `issues` lists each problem as `path: message`, for example
`codecs.timestamptz: Invalid type`. Exit code 2.

## `env_invalid` [#env_invalid]

An environment variable the CLI reads has a malformed value, for example a
`DATABASE_URL` that isn't a URL. `issues` lists each variable. Exit code 2.

## `failed` [#failed]

The command ran and reported a failure, such as a stale file under
`--check`. `exitCode` is the command's own exit code, usually 1.

## `internal` [#internal]

Something the CLI didn't expect went wrong, such as a database that refused
the connection. `detail` has the underlying message. Exit code 1. If it
looks like a bug, [open an issue](https://github.com/ScaleDockHQ/better-supabase/issues)
with the command and the message.

# gen

> Generate database types, typed models, relation metadata and validators.

Source: https://bettersupabase.com/docs/cli/gen

```bash
better-supabase gen [--tasks <list>] [--check] [--watch] [--snapshot <file>] [--db-url-stdin | --project-ref <ref>]
better-supabase gen --metadata <path|-> [--emit <file> | --out <dir> [--check]]
```

`gen` is the one command to run after a schema or config change. It writes
the typed client from the database, then every other generated file the
config sets up, so the config holds the options instead of each command line.

## Tasks [#tasks]

| Task       | Runs by default when                                        | Does what                                                                        |
| ---------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `types`    | always                                                      | Writes the files this page describes                                             |
| `sql`      | `sql.modules` lists a module, or `realtime.policies` is set | [`sql sync`](/docs/blocks/sql): rewrites the SQL module files                    |
| `spec`     | the `specs.entry` file exists                               | [`spec emit`](/docs/cli/spec), with `specs.failOn` and `specs.manifest`          |
| `seed`     | the `seed.entry` file exists                                | [`seed`](/docs/cli/local#seed): renders the fixtures to SQL                      |
| `scaffold` | `scaffold.api` is set                                       | Rewrites the generated file of [`scaffold api`](/docs/cli/scaffold), never yours |
| `env`      | never; list it to run it                                    | [`env`](/docs/cli/local#env): writes the local stack's keys to `env.output`      |

The tasks run in that order, since later ones read what earlier ones write.
Pin the list in the config with `gen.tasks`, or pass `--tasks` for one run:

```ts title="better-supabase.config.ts"
export default defineConfig({
  gen: { tasks: ["types", "sql", "spec"], watchInterval: 1000 },
});
```

```bash
better-supabase gen --tasks spec,seed
```

A listed task always runs, even when the config doesn't set it up, so a
missing entry file fails loudly instead of being skipped. A run that writes
stops at the first task that fails. `--check` runs every task and reports
everything that is out of date, and leaves `env` out because it writes local
keys rather than a committed file. With one task, the output is that task's
own; with several, each gets a section headed by its name.
`gen --metadata` reads the schema from a document and runs no other task.

`gen` introspects your database with
[`@supabase/postgrest-typegen`](https://github.com/supabase/postgrest-typegen),
the same generator behind `supabase gen types typescript`, and writes:

1. `database.types.ts`, what `supabase gen types` prints for the same schema
   (CI checks this against the Supabase CLI), plus a `ComputedFields` key on
   each table and view that names its computed fields, so
   `createClient<Database>()` works as usual;
2. the main module (`output`), with models in your casing, enum and CHECK
   constants, typed constraint names, and the `schema` object;
3. the runtime metadata next to it (`generated.meta.js`, with
   `generated.meta.d.ts`): tables, columns, relations with foreign key
   actions, and functions. Relations follow each foreign key to the table it
   references; the copies PostgREST lists for views over that table are
   left out, so adding a view never renames another table's relations. It is plain JavaScript typed as `SchemaMeta`, so
   TypeScript doesn't check a large object literal in every program that
   imports the main module. Each table and function is one line of JSON, which
   keeps the module small to load and a schema change visible per entry in a
   diff. Commit both files with the main module;
4. one file per configured generator (`zod()`, `valibot()`, `jsonSchema()`,
   `standardSchema()`);
5. with `readSets` configured, the `read-sets` SQL module file: one function per
   [read set](/docs/repository/read-sets). `gen` imports those modules after
   writing the main module, since they import it.

It doesn't need Docker or the Supabase CLI: it only needs a way to run SQL.
Files are only rewritten when their contents change, so watchers and bundlers
don't rebuild for nothing. Line endings don't count as a change: a checkout
with `core.autocrlf` keeps its `\r\n` files, and `--check` passes on them.

`database.types.ts` is formatted with [oxfmt](https://oxc.rs/docs/guide/usage/formatter),
an optional peer. Without it, `gen` writes the file unformatted and says so;
install it with `pnpm add -D oxfmt`. better-supabase accepts any oxfmt from
0.66.0 up to, but not including, 1.0. `@supabase/postgrest-typegen` pins exactly 0.66.0 as its
peer, the version that formats like `supabase gen types`, so pnpm
warns about a newer oxfmt until you allow it in `pnpm-workspace.yaml`:

```yaml title="pnpm-workspace.yaml"
peerDependencyRules:
  allowedVersions:
    "@supabase/postgrest-typegen>oxfmt": "0.71.0"
```

Names are sorted by code point, not by locale, so every machine writes the
same files.

## Introspection cache [#introspection-cache]

Before reading a database, `gen` runs one query that hashes the system
catalogs (tables, columns, constraints, policies, functions, grants, comments,
role memberships and role settings; planner statistics are left out). When the hash, the
schemas, the better-supabase version and the pinned typegen version match the
last run, it reuses the
snapshot cached in `node_modules/.cache/better-supabase` and skips
introspection. Deleting that folder clears the cache.

`--watch` keeps one connection open and runs that query every `--interval`
milliseconds (`gen.watchInterval` in the config, default 2000), so it reads
the full schema only after a change. It also checks the config file's
modification time, and when the file changes it loads it again and runs every
task again. A change to the spec entry, its overlays or the seed entry reruns
the tasks after `types`, without reading the schema; a config that fails to load is reported once and kept
until the next edit fixes it. Each run imports the `readSets` and `realtime.policies`
modules again, with the project files they import (such as the main module
the run wrote); packages in `node_modules` load once. Ctrl-C ends the
connection, including a query in flight. Connecting gives up after 10 seconds,
and each introspection query after 2 minutes.

## Options [#options]

| Option                 | Effect                                                                                                                                                           |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--tasks <list>`       | The [tasks](#tasks) to run, comma-separated. Defaults to `gen.tasks`, then every task the config sets up.                                                        |
| `--check`              | Writes nothing; exits 1 when a generated file is out of date and prints a diff of each one. Use it in CI.                                                        |
| `--watch`              | Runs again when the schema, the config file or a task's input changes (polls every `--interval` ms).                                                             |
| `--interval <ms>`      | Milliseconds between checks in `--watch`. Defaults to `gen.watchInterval`, then 2000.                                                                            |
| `--snapshot <file>`    | Reads a saved snapshot instead of connecting.                                                                                                                    |
| `--db-url-stdin`       | Reads the connection string from stdin; overrides the config, `$DATABASE_URL` and `$SUPABASE_DB_URL`.                                                            |
| `--project-ref <ref>`  | Reads a hosted project through the Management API.                                                                                                               |
| `--metadata <path\|->` | Reads a `GeneratorMetadata` document (`-` for stdin) and prints one file on stdout. See [From a GeneratorMetadata document](#from-a-generatormetadata-document). |
| `--emit <file>`        | With `--metadata`, the file to print: `schema` (default), `types`, `zod`, `valibot`, `json-schema` or `standard-schema`.                                         |
| `--out <dir>`          | With `--metadata`, writes every generated file into this directory instead of printing one.                                                                      |

With `--json`, `gen` prints `{ "tables", "written" }`, and `gen --check`
prints `{ "upToDate", "stale" }`. When several tasks run, it prints
`{ "tasks": { "<task>": { "code", "data" } } }` instead, with each task's
document under its name.

A connection string holds the database password, so no command takes one as
an argument, where it would land in shell history and the process list. Set
`$DATABASE_URL` or `$SUPABASE_DB_URL`, or pipe it in:

```bash
printf %s "$PROD_DB_URL" | better-supabase gen --check --db-url-stdin
```

## Where the schema comes from [#where-the-schema-comes-from]

In order:

1. `--snapshot`, `--db-url-stdin` or `--project-ref`;
2. `source.snapshot` in the config, when none of those flags is passed;
3. `source.dbUrl` or `source.projectRef` in the config;
4. a [Supabase Lite](/docs/platform/lite) project's `[db] driver` in
   `supabase/config.toml` (turn this off with `source.lite: false`);
5. `$DATABASE_URL`;
6. `$SUPABASE_DB_URL`, the name the Supabase CLI and `better-supabase env`
   use;
7. the local stack, on the `[db] port` from `supabase/config.toml` (54322 by
   default).

On a Lite project, the `postgres` driver reads its `[db] url`. The `pglite`
and `sqlite-postgres` drivers replay `supabase/migrations` and then the
declarative schema files into an in-memory PGlite with Lite's auth schema,
and introspect that, so `gen` needs no running server. The bare `sqlite`
driver takes native SQLite DDL, which `gen` can't read: switch to
`sqlite-postgres`, or save a snapshot.

### Hosted projects without a database password [#hosted-projects-without-a-database-password]

`source.projectRef` (or `--project-ref`) runs the introspection queries
through the Management API's read-only SQL endpoint
(`POST /v1/projects/{ref}/database/query/read-only`). You need a
[personal access token](https://supabase.com/dashboard/account/tokens) in
`SUPABASE_ACCESS_TOKEN`, not the database password, and the queries run as a
read-only role.

Create a scoped token for this: limit it to the project and the read-only
query permission the
[personal access tokens guide](https://supabase.com/docs/guides/platform/personal-access-tokens)
lists for that endpoint. A classic token carries your whole account, on every
organization and project, which is more than CI or an agent needs. A scoped
token that lacks the permission answers "You do not have permission to
perform this action".

```ts title="better-supabase.config.ts"
export default defineConfig({
  source: { projectRef: "abcdefghijklmnopqrst" },
});
```

```bash
SUPABASE_ACCESS_TOKEN=sbp_... better-supabase gen --check
```

`SUPABASE_API_URL` points it at another Management API host.

## What the metadata knows [#what-the-metadata-knows]

The generated `schema` carries what the runtime needs to stay correct without
extra round trips:

* **Read-only columns.** Generated columns, `identity always` columns and
  view columns Postgres marks as not insertable or updatable are left out of
  the `Insert` and `Update` types, and writes to them fail before a request is
  sent.

* **Unique keys and constraint names.** `findUnique` accepts the primary key
  or any named unique key, and `UniqueConstraint`, `CheckConstraint` and
  `ForeignKeyConstraint` types narrow `isConflict(error, 'customers_kvk_key')`.
  See [unique keys and errors](/docs/repository/unique-and-errors).

* **Foreign key actions.** `on delete cascade`, `set null` and `set default`
  are recorded on relations, so a delete invalidates the tables it changes.
  See [caching](/docs/concepts/caching).

* **Codecs.** With `codecs` in the config, `int8`, `numeric` and
  `timestamptz` columns are read exactly (as `bigint`, `string` or
  `Temporal.Instant`; `timestamp` becomes `Temporal.PlainDateTime`), and the
  generated validators match. See [Temporal](/docs/concepts/temporal).

* **Columns a CHECK makes not null.** A nullable column with
  `check (slug is not null)`, alone or as a term of a top-level `and`, is
  typed and validated as not null. It is required on insert unless it has
  a default or the table has a row-level `before insert` trigger, which can
  fill it before the CHECK runs. A term under `or` or `not`, and a
  `not valid` constraint, change nothing. Prefer `not null` on the column
  itself when you can.

* **Columns the database fills on insert.** A not-null column without a
  default is required in `InsertOf` and the insert validators. When a
  trigger or another database rule fills it, list its database name in
  `tables.<table>.insertOptional` and `gen` makes it optional on insert
  while the row type stays not null:

  ```ts title="better-supabase.config.ts"
  export default defineConfig({
    tables: { invoices: { insertOptional: ["number"] } },
  });
  ```

  `gen` fails when a listed name is not a column of the table.

* **Function arguments and results.** Every argument in `Functions` accepts
  `null`, since Postgres passes `null` to any function, and arguments with a
  default are optional. Functions that return rows of a table or a
  `returns table (...)` record get a `result` entry, so `db.$rpc` returns them
  in your [casing](/docs/concepts/casing#function-results).

* **Overloaded functions.** A function with several signatures gets a union
  of `{ Args; Returns }` in `Functions`, one member per overload in
  signature order, as in `database.types.ts`. PostgREST picks an overload by
  the argument names, so `db.$rpc` does too: a call type-checks against any
  overload, returns the type of the overload whose names it passes, and
  decodes the result with that overload's `result`. Overloads with the same
  argument names (`pick(value integer)` and `pick(value text)`) return the
  union of their types, and PostgREST can't choose between them at runtime
  either. An overload without arguments is typed `Record<PropertyKey, never>`,
  so it never matches a call that passes one.

* **Nullable function results.** Postgres can't promise that a function
  returns a value: a `strict` function returns `null` for a `null`
  argument, a SQL function returns `null` when its query finds no row, and
  an aggregate in a `returns table` column is `null` over no rows. So a
  scalar result, each element of a `setof` scalar, a single table row and
  each `returns table` column are typed `| null`. Rows of `returns setof`
  a table are not, and neither is `void` or `Json`, which already includes
  `null`. When you know a result is never null, say so in the config:

  ```ts title="better-supabase.config.ts"
  export default defineConfig({
    functions: {
      open_ticket_count: { notNull: true },
      customer_note_counts: { notNull: ["customer_id", "note_count"] },
    },
  });
  ```

  `notNull: true` covers the whole result, and a list names the
  `returns table` columns (database names) that are never null. `gen` fails
  on a function or column it can't find.

## Documentation in the validators [#documentation-in-the-validators]

The `zod()`, `valibot()`, `jsonSchema()` and `standardSchema()` generators carry what the
database says about a table into the schemas, so an OpenAPI document or an MCP
tool built from them describes each field:

* Each table schema gets a title from the table name (`customer_tags` becomes
  `Customer tags`, then `Customer tags insert` and `Customer tags update`) and
  a description from `comment on table`.
* Each field gets a description from `comment on column`. A comment line that
  starts with `@example` adds an example: JSON when it parses (`@example 42`,
  `@example "Acme B.V."`), text otherwise. Examples the column's type rejects
  are left out.
* Simple CHECK constraints become bounds the validators enforce. Comparisons
  of a numeric column with a constant (`price >= 0`, `rating between 1 and 5`)
  become `minimum`, `maximum` and their exclusive forms, and comparisons of
  `length` or `char_length` with a constant become `minLength` and `maxLength`.
  Terms joined by `or`, other functions and comparisons between columns are
  left out.

```sql title="supabase/schemas/customers.sql"
create table public.customers (
  name text not null check (char_length(name) between 1 and 200)
  -- ...
);

comment on table public.customers is 'Companies the organization sells to.';
comment on column public.customers.name is 'Trading name.
@example "Acme B.V."';
```

```ts title="src/lib/supabase/generated.zod.ts"
export const customersInsert = z
  .object({
    name: z
      .string()
      .min(1)
      .max(200)
      .meta({ description: "Trading name.", examples: ["Acme B.V."] }),
    // ...
  })
  .meta({
    title: "Customers insert",
    description: "Companies the organization sells to.",
  });
```

Valibot gets the same through `v.title`, `v.description`, `v.examples`,
`v.minLength` and `v.minValue` in a pipe, and the JSON Schema through
`title`, `description`, `examples`, `minLength` and `minimum`. The
`standardSchema()` output carries them in each field spec.

The generated metadata carries the comments too, so the API documents that
[`defineApi`](/docs/specs) and `createOpenApi` render describe each table
and column without a validator. A table or column comment becomes
`description` in the metadata (without its `@example` lines), and a comment
line that starts with `@deprecated` sets `deprecated: true`. In the
document, the table's description goes on its schemas and its tag, each
column's on its property, and a deprecated table marks its schemas and
operations deprecated. A tag description set on the resource wins over the
table comment.
Examples stay in the validators; pass `examples` to `defineApi` to put rows
in the document.

### Coming from supabase-to-zod [#coming-from-supabase-to-zod]

`supabase-to-zod` converts the `database.types.ts` file into zod 3 schemas
through `ts-to-zod`. The `zod()` generator writes zod 4 schemas from the
database catalog instead, so they carry the CHECK bounds, comments and codecs
above, and it keeps separate `Row`, `Insert` and `Update` schemas per table.
Replace the `supabase-to-zod` script with `generators: [zod()]` and import
`<table>Row` from `generated.zod.ts`.

## Standard Schema without a validation library [#standard-schema-without-a-validation-library]

`standardSchema()` writes `<output>.standard.ts`: a schema per table for its
Row, Insert and Update shapes that needs no zod or valibot install. Each one
implements [Standard Schema](https://standardschema.dev) (`~standard.validate`)
and Standard JSON Schema (`~standard.jsonSchema`), so it works anywhere a
Standard Schema does: the [validation plugin](/docs/plugins/validation),
`validate()`, form libraries, oRPC and MCP tool inputs.

```ts title="better-supabase.config.ts"
import { defineConfig, standardSchema } from "better-supabase/config";

export default defineConfig({
  output: "src/lib/supabase/generated.ts",
  generators: [
    standardSchema({ json: { "customers.metadata": "./schemas.ts#metadata" } }),
  ],
});
```

```ts title="src/lib/supabase/generated.standard.ts"
import { type TableSchema, tableSchema } from "better-supabase";

export const customersInsert: TableSchema<InsertOf<"customers">> = tableSchema({
  title: "Customers insert",
  fields: {
    name: { kind: "string", minLength: 1, maxLength: 200 },
    status: {
      kind: "enum",
      values: ["lead", "active", "archived"],
      optional: true,
    },
    metadata: {
      kind: "json",
      nullable: true,
      optional: true,
      schema: metadata,
    },
  },
});

export const validators = {
  customers: { insert: customersInsert, update: customersUpdate },
};
```

The checks match the `zod()` output: uuid, integer and ISO date formats,
enum values, CHECK bounds, `null` only on nullable columns, and Temporal
values on `instant` and `plainDateTime` codec columns. A present key never
accepts `undefined`, and unknown keys are dropped from the validated value.
`json` takes a Standard Schema per typed jsonb column; its issues keep their
path under the column, and its JSON Schema is used when it has one.

`~standard.jsonSchema.input()` and `output()` return the same JSON Schema the
`jsonSchema()` generator writes for that shape, for the `draft-2020-12`,
`draft-07` and `openapi-3.0` targets:

```ts
const schema = customersInsert["~standard"].jsonSchema.input({
  target: "draft-07",
});
```

## Snapshots [#snapshots]

```bash
better-supabase introspect --out supabase/snapshot.json
better-supabase gen --snapshot supabase/snapshot.json
```

A committed snapshot lets CI and contributors generate without a database.
[`introspect`](/docs/cli/introspect) writes and checks it.

The output is the same after `supabase db reset` and on every machine:
object ids come from names, and the arguments of each function keep their
declaration order. That matters for extension functions with unnamed
arguments, such as pgvector's distance functions and citext's casts, whose
argument order the catalog query alone does not fix.

## From a GeneratorMetadata document [#from-a-generatormetadata-document]

`gen --metadata` generates from the JSON document that
`@supabase/postgrest-typegen` defines (`GeneratorMetadata`, versioned by
`GENERATOR_METADATA_VERSION`), the same document
[`@supabase/typegen`](https://github.com/supabase/sdk/tree/main/packages/typegen)
pipes to out-of-process generators. Pass a file, or `-` to read stdin:

```bash
better-supabase gen --metadata - < metadata.json > src/lib/supabase/generated.ts
```

It follows the registry's contract for an out-of-process tool:

* stdout holds only the generated file. Notices, warnings and the oxfmt
  notice go to stderr.
* It exits 0 on success, 1 when generation fails (for example without
  `@supabase/postgrest-typegen` installed), 2 on a usage error, and 65 (the code the registry
  maps to `MetadataRejectedError`, as for Dart's `supabase_typegen`) when it
  can't read the document, the document is not JSON, its `version` is not the
  `GENERATOR_METADATA_VERSION` this release reads, or the schema rejects it.
  The reason is on stderr.
* It reads `better-supabase.config.ts` from the working directory when there
  is one (`casing`, `schemas`, `tables`, `codecs`, generator options), and the
  defaults otherwise.

`--emit` picks the file:

| `--emit`          | Prints                                                                                                                                                    |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `schema`          | The `defineSchema` module, with the metadata written into it instead of a separate `generated.meta.js`. It imports `Database` from `databaseTypesOutput`. |
| `types`           | `database.types.ts`, formatted when oxfmt is installed.                                                                                                   |
| `zod`             | The `zod()` generator's file, with the options from the config when it lists `zod()`.                                                                     |
| `valibot`         | The `valibot()` generator's file, likewise.                                                                                                               |
| `json-schema`     | The `jsonSchema()` generator's document, likewise.                                                                                                        |
| `standard-schema` | The `standardSchema()` generator's file, likewise.                                                                                                        |

`--out <dir>` writes the usual files (`database.types.ts`, the main module,
its metadata module and each configured generator's file) into one directory
instead, and `--check` compares them there. A generator with its own `output`
keeps that path. It leaves the read-set SQL module
and the files an earlier `gen` wrote alone.

The document carries no more than the Data API types need, so a few things
the database connection adds are missing. Relations, primary keys, enums,
single-column unique keys and single-column CHECK unions stay typed; unique
keys and checks get the names Postgres gives them by default
(`<table>_<column>_key`, `<table>_<column>_check`). Multi-column unique keys
and checks, foreign key actions, indexes, triggers, policies, grants, buckets
and the realtime publication are absent, and a notice on stderr says so. Run
`gen` against the database for the full model.

`doctor --metadata` reads the same document. Checks that need what it lacks
are skipped with an info finding, and the advisors and live checks are
skipped as for a saved snapshot.

### As a `@supabase/typegen` language [#as-a-supabasetypegen-language]

The registry runs an out-of-process language in the project directory with
the sorted document on stdin. An `externalLanguage` entry for better-supabase
runs `npx better-supabase gen --metadata -`:

```ts title="packages/typegen/src/languages/better-supabase.ts"
import { externalLanguage } from "./external.ts";

export const betterSupabase = externalLanguage(
  "better-supabase",
  [
    {
      name: "emit",
      audience: "user",
      kind: "choice",
      choices: [
        "schema",
        "types",
        "zod",
        "valibot",
        "json-schema",
        "standard-schema",
      ],
      default: "schema",
      help: "The file to generate: the defineSchema module, database.types.ts or a validator file",
    },
  ],
  {
    command: "npx",
    args: (_metadata, options) => [
      "--no-install",
      "better-supabase",
      "gen",
      "--metadata",
      "-",
      "--emit",
      String(options.emit),
    ],
    installHint:
      "Install Node.js, then run `npm install -D better-supabase @supabase/postgrest-typegen` in the project.",
    classify: (result) => {
      if (
        result.stderr.includes("could not determine executable to run") ||
        result.stderr.includes(
          'needs the "@supabase/postgrest-typegen" package',
        )
      ) {
        return {
          kind: "not-installed",
          tool: "the better-supabase package",
          installHint:
            "Run `npm install -D better-supabase @supabase/postgrest-typegen` in the project that should receive the types, then generate from that directory.",
        };
      }
      if (result.exitCode === 65) return { kind: "metadata-rejected" };
      return undefined;
    },
  },
);
```

Once the entry is in the registry's `languages`, `user` options become flags
of `supabase gen types`, so `supabase gen types --lang better-supabase --emit zod`
prints what `better-supabase gen --metadata - --emit zod` prints for the same
database. The upstream registry doesn't ship this entry, so without it, pipe
the document to `better-supabase gen --metadata -` yourself.

# CLI

> Codegen, SQL and diagnostics for better-supabase projects.

Source: https://bettersupabase.com/docs/cli

```bash
pnpm better-supabase <command> [options]
```

| Command                                                | What it does                                                                                    |
| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
| [`init`](/docs/cli/init)                               | Writes the config, `lib/supabase/index.ts` and glue for the frameworks it finds                 |
| [`add`](/docs/cli/init#add)                            | Adds an integration: `next`, `hono`, `orpc`, `edge`, `mcp`, `client`, `react`                   |
| [`gen`](/docs/cli/gen)                                 | Generates types, metadata and validators, then every other generated file the config sets up    |
| [`introspect`](/docs/cli/introspect)                   | Saves a schema snapshot, for codegen without a database                                         |
| [`env`](/docs/cli/local#env)                           | Writes the local stack's URL and keys to `.env.local`                                           |
| [`keys`](/docs/cli/local#keys)                         | Creates or rotates an ES256 signing key for the local stack                                     |
| [`seed`](/docs/cli/local#seed)                         | Renders typed fixtures to seed SQL                                                              |
| [`spec`](/docs/cli/spec)                               | Writes, checks and validates the OpenAPI, AsyncAPI and Arazzo documents of a `defineApi` module |
| [`scaffold api`](/docs/cli/scaffold)                   | Writes an API module, resource routes and an OpenAPI route for Hono or Next.js                  |
| [`openapi emit`](/docs/cli/local#openapi-emit)         | Writes `openapi.json` from your `createOpenApi` module                                          |
| [`sql`](/docs/blocks/sql)                              | Lists, adds, syncs, upgrades and prints SQL modules                                             |
| [`config`](/docs/cli/config#print-the-resolved-config) | Prints the resolved config, with every default filled in                                        |
| [`codemod`](/docs/cli/codemod)                         | Rewrites imports and calls for renamed better-supabase APIs                                     |
| [`doctor`](/docs/cli/doctor)                           | Checks RLS, indexes, drift, auth config and env files; text, JSON, SARIF or GitHub annotations  |
| [`skills`](/docs/for-ai-agents)                        | Installs the Agent Skills that ship with the package                                            |

Options you would pass on every run belong in the
[config](/docs/cli/config#flags-and-the-config): a flag wins for one run, then
the active environment's block, then the config, then the default. With the
config filled in, `better-supabase gen` is the whole build step.

Every command takes `--help`. Commands that write files accept `--check`
(exit 1 on drift, with a unified diff of each stale file) or `--dry-run`.
In a terminal, output is colored (set `NO_COLOR` to turn it off), `init` and
`add` ask what they need, and database work shows a spinner on stderr.
A mistyped command gets a suggestion: `Did you mean "gen"?`.

## Global options [#global-options]

| Option            | Effect                                                                                                                                                                                   |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--cwd <dir>`     | Project directory. Defaults to the current one. `supabase/config.toml` is read from here or the nearest parent that has one ([monorepos](/docs/guides/monorepo#the-cli-in-a-workspace)). |
| `--config <file>` | Config file, relative to `--cwd`. Defaults to the nearest `better-supabase.config.*` in `--cwd` or a parent directory.                                                                   |
| `--env <name>`    | Applies the config's [environment block](/docs/cli/config#environments) with that name. Defaults to `$BETTER_SUPABASE_ENV`, then `ci` when `$CI` is set.                                 |
| `--json`          | Prints one JSON document on stdout, and errors as Problem Details. Never prompts.                                                                                                        |
| `--yes`, `-y`     | Never prompts; uses the flags and the defaults.                                                                                                                                          |
| `--help`, `-h`    | Prints the usage of the CLI, or of one command.                                                                                                                                          |
| `--version`       | Prints the version.                                                                                                                                                                      |

Prompts only run in a terminal. In CI (`$CI` is set), and under `--json` or
`--yes`, commands use their flags and defaults instead of asking.

## Exit codes and JSON output [#exit-codes-and-json-output]

The CLI exits with 0 on success, 1 when a command fails or finds a problem
(`--check` drift, doctor errors), and 2 when the command line, the config or
the environment needs a change. Every error has a code, listed on the
[errors page](/docs/cli/errors).

With `--json`, stdout holds exactly one JSON document: the command's result
(`gen` prints `{ "tables", "written" }`, `doctor` its report), or a
[Problem Details](https://www.rfc-editor.org/rfc/rfc9457) document when it
fails. Progress and diagnostics go to stderr, so
`better-supabase gen --check --json | jq .stale` stays parseable.

## Environment [#environment]

| Variable                | Used by                                                                    |
| ----------------------- | -------------------------------------------------------------------------- |
| `DATABASE_URL`          | `gen`, `introspect`, `doctor` and `seed --apply`, when nothing else is set |
| `SUPABASE_DB_URL`       | The same commands, when `DATABASE_URL` is unset too                        |
| `SUPABASE_ACCESS_TOKEN` | `--project-ref` and `source.projectRef`                                    |
| `SUPABASE_API_URL`      | Another Management API host                                                |
| `SUPABASE_BIN`          | The Supabase CLI `env` runs, instead of `supabase` on the `PATH`           |
| `CI`                    | Turns prompts off, and applies the config's `ci` environment               |
| `BETTER_SUPABASE_ENV`   | The config environment to apply when `--env` is not passed                 |

The CLI checks these once per run; a malformed URL stops it with
[`env_invalid`](/docs/cli/errors#env_invalid). Connection strings hold the
database password, so no command takes one as an argument. Set
`$DATABASE_URL`, or `$SUPABASE_DB_URL` (the name the Supabase CLI and
`better-supabase env` use), or pipe one in with `--db-url-stdin`. When both
are set, `$DATABASE_URL` wins.

## Programmatic use [#programmatic-use]

The CLI never calls `process.exit`. `run` returns the exit code and output,
which is how the tests drive it:

```ts
import { run } from "better-supabase/cli";

const { code, stdout, stderr } = await run(["gen", "--check"], {
  env: process.env,
});
```

`run` reads no process globals: it sees only the `env` you pass (none by
default), and `--cwd` resolves against the `cwd` option. `help()` returns the
usage text, and `help(command)` the text for one command.

Add your own commands with `defineCliCommand` and `registerCommand`. Commands
are [citty](https://github.com/unjs/citty) definitions: `args` declares the
options, and `run` gets the parsed arguments and the context (`cwd`,
`config`, `io`, `env`, `json`, `signal`). Throw a `CliError` to report a
coded error, and return `data` to give `--json` a document. Options listed in `lists` may repeat or
take commas, and `list()` splits them.

```ts title="scripts/cli.ts"
import {
  defineCliCommand,
  list,
  registerCommand,
  run,
} from "better-supabase/cli";

registerCommand(
  "schemas",
  defineCliCommand({
    meta: { name: "schemas", description: "Prints the schemas codegen reads" },
    args: { only: { type: "string", description: "Schemas to keep" } },
    lists: ["only"],
    run: async (args, { config }) => {
      const only = list(args.only);
      const schemas = config.schemas.filter(
        (name) => only.length === 0 || only.includes(name),
      );
      return { code: 0, output: schemas.join("\n") };
    },
  }),
);

const { code } = await run(process.argv.slice(2), {
  cwd: process.cwd(),
  env: process.env,
});
process.exitCode = code;
```

Commands written for 0.2, `(context) => CommandResult`, still register with
`registerCommand(name, command, help)`. That overload is deprecated.

# init and add

> Scaffold the config, the data layer and framework glue for your project.

Source: https://bettersupabase.com/docs/cli/init

```bash
npx better-supabase init   # before better-supabase is installed
pnpm better-supabase init  # once it is a dependency
```

`init` reads your `package.json` and writes:

* `better-supabase.config.ts` with `casing` written out (`--casing camel|snake`;
  `init` picks `camel` by default, while a config without `casing` means
  `snake`). It lists the integrations it set up under `integrations`, and
  carries commented `gen`, `env` and `doctor` entries for the options you
  would otherwise pass on every run (see [Flags and the config](/docs/cli/config#flags-and-the-config))
* `src/lib/supabase/index.ts`, which exports `betterSupabase` and the `Models` and `Functions` types
* glue for every framework it finds, the same files `add` writes

When `supabase/config.toml` exists and has no `[experimental.pgdelta]` table,
`init` adds one with `enabled = true`, so `supabase db schema declarative sync`
diffs your schema files with pg-delta, the engine `supabase init` uses for new
projects. A table that sets `enabled = false` stays as it is, and `init` prints
the steps to switch (see [BS316](/docs/cli/doctor#bs316)).

It then prints the next steps: the install command for your package manager,
`supabase start`, `better-supabase env` and `better-supabase gen`. Files that
already exist are never overwritten unless you pass `--force` or confirm the
prompt, and `--dry-run` shows what would be written. The names and the layout are described in [Naming](/docs/concepts/naming).

In a terminal, `init` asks for the casing (unless you pass `--casing`), the
integrations, with the detected ones checked (unless you pass `--with`), and
whether to overwrite files that exist. `add` without names asks which
integrations to add. It never asks in CI, when stdin or stdout is not a
terminal, or with `--yes`, which takes the flags and the defaults.

| Found in `package.json` | Integrations           |
| ----------------------- | ---------------------- |
| `next`                  | `next` (and `client`)  |
| `hono`                  | `hono`                 |
| `@orpc/server`          | `orpc`                 |
| `vite`, `expo`          | `client`               |
| `@tanstack/react-query` | `react` (and `client`) |

Add more with `--with edge,mcp`. A project with only `edge` and `mcp` gets no
`src/lib/supabase/index.ts`: Edge Functions import `betterSupabase` from
`supabase/functions/_shared/supabase.ts`.

## Supabase Lite [#supabase-lite]

```bash
pnpm better-supabase init --lite
```

`--lite` sets the project up for [Supabase Lite](/docs/platform/lite). The
next steps install `@supabase/lite` as a dev dependency instead of `pg`, run
`npx lite init` and `npx lite dev` instead of `supabase init` and
`supabase start`, and ask you to set `jwt_secret = "env(SUPABASE_JWT_SECRET)"`
in `supabase/config.toml` with a secret of 32 or more characters in `.env`.
Pass `backend: 'lite'` to `createServer` (or the framework adapter) so the
server verifies Lite's HS256 tokens.

## Workspaces [#workspaces]

At the root of a pnpm, npm, yarn or bun workspace (a `pnpm-workspace.yaml`,
or `workspaces` in `package.json`), `init` writes into one package instead of
the root. In a terminal it asks which package owns the runtime, with the one
that already depends on better-supabase (or else the first with a framework)
selected. Otherwise pass it:

```bash
pnpm better-supabase init --package packages/runtime
```

`init` then detects the frameworks and `tsconfig.json` of that package, writes
the config and the glue there, and prints next steps that target it:
`pnpm --filter @acme/runtime add ...` (or `npm install -w`, `yarn workspace`,
`bun add --cwd`) and `better-supabase gen --cwd packages/runtime`. Without
`--package` and without a terminal, `init` stops with an error that lists the
packages; `--package .` writes at the root as before. See
[monorepos](/docs/guides/monorepo) for how to split the runtime from domain
packages.

## add [#add]

```bash
pnpm better-supabase add hono mcp
```

`add` without an argument sets up every integration in the config's
`integrations` list whose files are missing, which is how a fresh checkout
or a new package in a workspace gets its glue. When you name integrations
the config doesn't list yet, `add` prints the `integrations` entry to paste.

| Integration | Writes                                                                                            |
| ----------- | ------------------------------------------------------------------------------------------------- |
| `client`    | `lib/supabase/client.ts` with the framework's public env variables                                |
| `next`      | `lib/supabase/server.ts` (with `import "server-only"`) and `proxy.ts`                             |
| `react`     | `providers.tsx` (`app/providers.tsx` in Next.js) and `lib/hooks.ts`                               |
| `hono`      | `server.ts`, which creates `bs` and adds the middleware and error handler                         |
| `orpc`      | `router.ts`, which creates `bs` and adds the auth middleware                                      |
| `edge`      | `supabase/functions/api` (`server.ts` creates `bs`) plus `supabase/functions/_shared/supabase.ts` |
| `mcp`       | `supabase/functions/mcp`, an MCP server as an Edge Function (`server.ts` creates `bs`)            |

Relative imports follow your `tsconfig.json`: they keep `.ts` when
`allowImportingTsExtensions` or `rewriteRelativeImportExtensions` is on.
Edge Functions always use `.ts` and get a `deno.json` import map. CI
typechecks every template against the library, so the files compile as
written. The names and the layout are described in [Naming](/docs/concepts/naming).

# introspect

> Save a schema snapshot so codegen and doctor run without a database.

Source: https://bettersupabase.com/docs/cli/introspect

```bash
better-supabase introspect [--out <file>] [--format <snapshot|generator-metadata>] [--check]
```

`introspect` reads your database the way [`gen`](/docs/cli/gen) does and
writes what it found to `supabase/snapshot.json`. Commit the file, set
`source.snapshot` in the config, and CI and contributors can run `gen` and
`doctor` without a database.

## Options [#options]

| Option                | Effect                                                                                       |
| --------------------- | -------------------------------------------------------------------------------------------- |
| `--out <file>`        | Where to write. Defaults to `supabase/snapshot.json`, or `supabase/generator-metadata.json`. |
| `--format <format>`   | `snapshot` (the default) or `generator-metadata`, postgrest-typegen's input on its own.      |
| `--check`             | Writes nothing; exits 1 when the file no longer matches the database.                        |
| `--snapshot <file>`   | Reads a saved snapshot instead of the database, to convert it with `--format`.               |
| `--db-url-stdin`      | Reads the connection string from stdin.                                                      |
| `--project-ref <ref>` | Reads a hosted project through the Management API.                                           |

The schema comes from the same places as for `gen`, in the
[same order](/docs/cli/gen#where-the-schema-comes-from). With `--json`,
`introspect` prints `{ "file", "written", "tables" }`, and `--check` prints
`{ "file", "upToDate" }`. It always reads the full schema, without the
[introspection cache](/docs/cli/gen#introspection-cache) `gen` uses.

## What the snapshot holds [#what-the-snapshot-holds]

The snapshot stores postgrest-typegen's own metadata plus the extras
better-supabase reads (policies, indexes, triggers, buckets, realtime), and
follows
[`snapshot-v2.json`](https://unpkg.com/better-supabase/schemas/snapshot-v2.json).
Row counts are left out so the file stays stable; tables Postgres estimates
at 10,000 rows or more only get `large: true`, which the
[`unbounded-read`](/docs/plugins/lint#unbounded-read) lint rule reads.

# Local development

> Env files, signing keys, typed seeds and OpenAPI files for the local stack.

Source: https://bettersupabase.com/docs/cli/local

## env [#env]

```bash
pnpm better-supabase env
```

`env` reads `supabase status` and writes the URL and keys to `.env.local`.
It updates the variables it manages in place and keeps every other line.
The browser variables get your framework's prefix (`NEXT_PUBLIC_`, `VITE_`,
`EXPO_PUBLIC_`), or the one you pass with `--prefix`:

| Variable                           | From                                                                                   |
| ---------------------------------- | -------------------------------------------------------------------------------------- |
| `<prefix>SUPABASE_URL`             | `API_URL`                                                                              |
| `<prefix>SUPABASE_PUBLISHABLE_KEY` | `PUBLISHABLE_KEY` (or the legacy `ANON_KEY`)                                           |
| `SUPABASE_SECRET_KEY`              | `SECRET_KEY` (or the legacy `SERVICE_ROLE_KEY`)                                        |
| `SUPABASE_DB_URL`                  | `DB_URL`                                                                               |
| `SUPABASE_JWT_SECRET`              | `JWT_SECRET`, for HS256 [test tokens](/docs/testing) (`asUser(..., { alg: 'HS256' })`) |

Values are never printed unless you pass `--print`. `env` warns when the
file isn't in `.gitignore`, and when your Supabase CLI only prints legacy keys.
`--out <file>` writes another file than `.env.local` (`env.output` in the
config sets it for every run, and `env.prefix` the prefix), and `--from <file>`
reads saved `supabase status -o json` output instead of running the
Supabase CLI.

The Supabase CLI's native stack (`[experimental] stack = true` in
`supabase/config.toml`, or `SUPABASE_EXPERIMENTAL_STACK=1`) runs without a
Docker daemon and rejects `supabase status -o json`. `env` then reads
`supabase status --env` instead. That output has no `JWT_SECRET`, so
`SUPABASE_JWT_SECRET` is left out, and HS256 test tokens use the CLI's default
secret.

## keys [#keys]

```bash
pnpm better-supabase keys
```

This writes an ES256 key to `supabase/signing_keys.json` with mode `0600`.
Point `[auth] signing_keys_path` at it, and local tokens are then signed the way
your hosted project signs them: asymmetrically, and verifiable through
JWKS. `--rotate` puts a new signing key first and keeps the old ones so
existing tokens still verify. `keys` refuses to overwrite an existing file
unless you pass `--force`, and `--out <file>` (or `keys.output` in the
config) writes somewhere else.

## seed [#seed]

Define fixtures once, in app casing, checked against your Insert types:

```ts title="supabase/seed.ts"
import { defineSeed } from "better-supabase/testing";
import { betterSupabase } from "../src/lib/supabase/index.ts";

export const ACME = "00000000-0000-4000-8000-000000000001";

export const seed = defineSeed(betterSupabase, {
  organizations: { acme: { id: ACME, name: "Acme", slug: "acme" } },
  customers: {
    first: {
      organizationId: ACME,
      name: "First customer",
      metadata: { tier: "pro" },
    },
  },
});
```

```bash
pnpm better-supabase seed          # writes supabase/seeds/000_better_supabase.sql
pnpm better-supabase seed --check  # fails in CI when the SQL is stale
pnpm better-supabase seed --apply  # also inserts into the local database
```

`--entry <file>` and `--out <file>` override `seed.entry` and
`seed.output`. `--apply` connects to `$DATABASE_URL` or `$SUPABASE_DB_URL`, or to the local stack;
to seed another database, pipe its connection string in with
`--db-url-stdin`.

* Tables are inserted parents first, following foreign keys.
* Missing columns use their defaults, and every insert is
  `on conflict do nothing`, so a seed can run twice.
* Unknown tables and columns are type errors. A column you leave out that
  has no default fails in the editor, not at `db reset`.
* Add the file to `[db.seed] sql_paths` in `supabase/config.toml`. `seed`
  prints the line when it's missing, and it never overwrites a file it
  didn't write.

Tests import the same rows: `seed.rows.customers.first`, and
`await seed.insert(sql)` in a `beforeAll`. Node loads `seed.ts` directly,
so use `.ts` extensions in its relative imports.

## openapi emit [#openapi-emit]

```bash
pnpm better-supabase openapi emit --check
```

This imports `openapi.entry` (default `src/lib/openapi.ts`), which exports
the [`createOpenApi`](/docs/standards/openapi) document as `openapi` or
default (or a function that returns it), and writes `openapi.json`.
`--check` writes nothing and fails when the file is out of date; like
[`spec check`](/docs/cli/spec), it compares the text and ignores line
endings. `--entry <file>` and `--out <file>` override `openapi.entry` and
`openapi.output`.

`openapi emit` is kept for existing projects. [`spec`](/docs/cli/spec)
writes the same document from a `defineApi` module, plus any other OpenAPI
version, AsyncAPI and Arazzo, with overlays, a cache and validation. The
`openapi` config key is a deprecated alias of `specs`: when `specs` is
unset, `spec` reads `openapi.entry` and `openapi.output` too.

# scaffold

> Write an API module, a REST resource per table and an OpenAPI route for Hono or Next.js, from the tables gen found.

Source: https://bettersupabase.com/docs/cli/scaffold

`scaffold api` writes the files that serve your tables as REST
[resources](/docs/specs/resources) and serve the OpenAPI document for them,
for Hono or Next.js. Run [`gen`](/docs/cli/gen) first: the tables come from
the metadata module it writes.

```bash
pnpm better-supabase scaffold api
pnpm better-supabase scaffold api --framework hono --tables customers,tags
```

## Options [#options]

| Option               | Default                                                | Effect                                                         |
| -------------------- | ------------------------------------------------------ | -------------------------------------------------------------- |
| `--framework <name>` | The one framework (`hono` or `next`) in `package.json` | Which files to write. With both or neither installed, pass it  |
| `--tables <list>`    | Every table and view in the `public` schema            | The tables to serve, comma-separated or repeated               |
| `--name <name>`      | `api`                                                  | The module name: `lib/<name>.ts` and `lib/<name>.generated.ts` |
| `--base-path <path>` | `/api`                                                 | Where the resources are served                                 |

Each option falls back to the same key under `scaffold.api` in the config
(`framework`, `tables`, `name`, `basePath`) before its default. Without a
`scaffold.api` entry, the command prints one for the flags you passed.

## From gen [#from-gen]

With `scaffold.api` set, [`gen`](/docs/cli/gen#tasks) runs scaffold as a
task after it writes the metadata, so a new table reaches the API without
another command:

```ts title="better-supabase.config.ts"
export default defineConfig({
  scaffold: { api: { framework: "hono", tables: ["customers", "tags"] } },
});
```

The task rewrites only `lib/<name>.generated.ts`, never the module you edit
or the routes, and `gen --check` fails when that file is out of date.

`--tables` names are checked against the metadata module
(`generated.meta.js`), and an unknown table fails with the list of known
ones. Without a metadata module, pass `--tables`; the names are used as
they are. `--name` is a file name without a folder.

## What it writes [#what-it-writes]

Paths are under `src/` when the project has one, else under the project
root.

| File                            | Framework | Written                |
| ------------------------------- | --------- | ---------------------- |
| `lib/api.generated.ts`          | both      | On every run           |
| `lib/api.ts`                    | both      | Once; yours after that |
| `app/api/[...path]/route.ts`    | Next.js   | Once; yours after that |
| `app/api/openapi.json/route.ts` | Next.js   | Once; yours after that |
| `api-routes.ts`                 | Hono      | Once; yours after that |

`lib/api.generated.ts` holds `tableNames`, `resources` and `basePath`, and
starts with an `@generated` header. Each run rewrites it from the options,
and a file at that path without the header stops the command, so it never
overwrites your code. Every other file is written when it doesn't exist and
kept (`Kept <path> (yours)`) when it does.

`lib/api.ts` is the API model, the entry `spec emit` reads:

```ts title="src/lib/api.ts"
import { defineApi } from "better-supabase/spec";

import { betterSupabase } from "./supabase";
import { basePath, resources, tableNames } from "./api.generated";

export { basePath, resources, tableNames };

export const api = defineApi(betterSupabase, {
  info: { title: "API", version: "1.0.0" },
  basePath,
  resources,
});
```

Describe the resources, add list queries and custom routes there (see
[API documents](/docs/specs)).

### Next.js [#nextjs]

A catch-all route serves every resource through `bs.resources`, and a
second route serves the document with
[`specResponse`](/docs/specs#serve-the-document):

```ts title="src/app/api/[...path]/route.ts"
import { basePath, resources } from "../../../lib/api";
import { bs } from "../../../lib/supabase/server";

export const { GET, POST, PATCH, PUT, DELETE } = bs.resources(resources, {
  basePath,
});
```

```ts title="src/app/api/openapi.json/route.ts"
import { specResponse } from "better-supabase/spec";

import { api } from "../../../lib/api";

const { document } = api.openapi();

export const GET = (request: Request) => specResponse(document, request);
```

### Hono [#hono]

`api-routes.ts` exports `apiRoutes(bs)`: the document at
`<basePath>/openapi.json`, `bs.middleware()` on `<basePath>/*`, and
`bs.resource(table)` for each table. Mount it in `src/server.ts`:

```ts title="src/server.ts"
import { createHono } from "better-supabase/hono";

import { apiRoutes } from "./api-routes";
import { betterSupabase } from "./lib/supabase";

export const bs = createHono(betterSupabase);

export const app = bs.app().route("/", apiRoutes(bs));
```

## Next steps [#next-steps]

The command prints what is left to do:

* With Hono, mount the routes in `server.ts`.
* When `specs.entry` is not the new `lib/api.ts`, set
  `specs: { entry: "src/lib/api.ts" }` in `better-supabase.config.ts`, so
  [`spec emit`](/docs/cli/spec) reads the model.
* Describe the resources in `lib/api.ts`. A later `scaffold api` only
  rewrites `api.generated.ts`, so run it again with a new `--tables` list
  to add or remove a table.

# spec

> Write, check and validate the OpenAPI, AsyncAPI and Arazzo documents your defineApi module renders, with a content-hash cache, watch mode and a diagnostics manifest.

Source: https://bettersupabase.com/docs/cli/spec

`spec` renders the documents your [`defineApi`](/docs/specs) module
describes and writes them to disk, so they live in the repository and CI can
check them for drift.

```bash
pnpm better-supabase spec emit       # write every configured document
pnpm better-supabase spec check      # exit 1 when a file is out of date
pnpm better-supabase spec validate   # also check against the official schemas
```

| Action     | What it does                                                                                                   |
| ---------- | -------------------------------------------------------------------------------------------------------------- |
| `emit`     | Renders each output and writes the files that changed                                                          |
| `check`    | Renders each output and compares it with the file on disk, ignoring line endings; writes nothing               |
| `validate` | Renders each output, writes nothing, and checks the document against its format's official JSON Schema as well |

A second positional argument keeps one format: `spec emit asyncapi`. When
no output of that format is configured, `spec` renders it on its own to the
format's default file name.

## The entry module [#the-entry-module]

`spec` imports `specs.entry` (default `src/lib/openapi.ts`). The module
exports one of:

* `api`, or a default export: a `defineApi()` result. Every format and
  version renders from its model.
* `openapi`, `asyncapi` or `arazzo`: a finished document of that format.
  `spec` writes it as it is, applies the overlays and runs the format's
  lint. Asking for another version than the document declares reports
  `document-version`.

Each export may also be a function that returns the value. Two more exports
are optional:

| Export     | Effect                                                                                                     |
| ---------- | ---------------------------------------------------------------------------------------------------------- |
| `formats`  | An array of extra [document formats](/docs/extending/document-formats); one with a built-in id replaces it |
| `cacheKey` | A string folded into the cache key, for inputs the model doesn't show                                      |

```ts title="src/lib/openapi.ts"
import { defineApi } from "better-supabase/spec";
import { betterSupabase } from "./supabase";

export const api = defineApi(betterSupabase, {
  info: { title: "CRM API", version: "1.0.0" },
  basePath: "/api",
  resources: { customers: true },
});
```

The module is loaded fresh on every run, so `--watch` sees your edits.

## Configure the outputs [#configure-the-outputs]

The `specs` key in `better-supabase.config.ts` lists the documents to
write:

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({
  specs: {
    entry: "src/lib/api.ts",
    outputs: [
      {
        format: "openapi",
        version: "3.1",
        output: "openapi.json",
        ui: "scalar",
      },
      {
        format: "openapi",
        version: "3.2",
        output: "public/openapi.yaml",
        overlays: ["specs/public.overlay.yaml"],
      },
      { format: "asyncapi", output: "asyncapi.json" },
      { format: "arazzo" },
    ],
    failOn: "warning",
  },
});
```

| Key       | Default                                           | What it sets                                                                                 |
| --------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `entry`   | `openapi.entry`, else `src/lib/openapi.ts`        | The entry module                                                                             |
| `outputs` | `[{ format: "openapi", output: "openapi.json" }]` | The documents to write                                                                       |
| `failOn`  | `"error"`                                         | The lowest severity that fails `emit`, `check` and `validate`: `error`, `warning` or `never` |

Each output takes:

| Key         | Default                                                                 | What it sets                                                                                    |
| ----------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `format`    | required                                                                | `openapi`, `asyncapi`, `arazzo`, or the id of a format the entry adds                           |
| `version`   | The format's default (`3.1` for OpenAPI)                                | The version to render                                                                           |
| `output`    | The format's file name (`openapi.json`, `asyncapi.json`, `arazzo.json`) | The file to write, relative to the project root                                                 |
| `overlays`  | `[]`                                                                    | [Overlay](/docs/specs/overlay) files (JSON or YAML), applied in order                           |
| `serialize` | `yaml` when `output` ends in `.yaml` or `.yml`, else `json`             | The file format                                                                                 |
| `ui`        | none                                                                    | Also write a reference page next to an OpenAPI output (see [Reference pages](#reference-pages)) |

Two outputs that write the same file are a usage error. Overlay files that
don't parse, or that have no `overlay` and `actions`, report
`overlay-invalid`.

JSON keeps the generated key order and ends in a newline. YAML is YAML 1.2
in the same order, with no anchors and no line folding, so the same document
always gives the same bytes.

### The `openapi` key [#the-openapi-key]

`openapi: { entry, output }` is a deprecated alias of `specs`. When `specs`
is unset, `openapi.entry` becomes `specs.entry` and `openapi.output` becomes
the output of the one OpenAPI document. The
[`openapi emit`](/docs/cli/local#openapi-emit) command still reads it.

## Options [#options]

| Option                     | Effect                                                                                                         |
| -------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `--spec-version <version>` | Render this version instead of the configured one (see below)                                                  |
| `--out <file>`             | Write the one selected output to this file. More than one match is a usage error                               |
| `--entry <file>`           | Use this entry module instead of `specs.entry`                                                                 |
| `--yaml`                   | Write YAML instead of JSON                                                                                     |
| `--ui <name>`              | Also write a reference page next to each OpenAPI output: `scalar`, `swagger`, `redoc`, `elements` or `rapidoc` |
| `--fail-on <level>`        | Exit 1 on findings at this level: `error`, `warning` or `never`. Defaults to `specs.failOn`                    |
| `--manifest <file>`        | Also write the diagnostics manifest to this file. Defaults to `specs.manifest`                                 |
| `--cache`, `--no-cache`    | Reuse renders whose inputs did not change (the default), or render every output again                          |
| `--watch`                  | With `emit`, render again whenever a watched file changes                                                      |
| `--interval <ms>`          | Milliseconds between checks in `--watch`. Defaults to `gen.watchInterval`, then `1000`                         |

`--spec-version` keeps the configured outputs at that version. When none
match, and the selection has one format, it renders that version to a file
of its own (`openapi-3.2.json`), so it never overwrites the output of the
configured version; with `--out` it writes there instead. When the
selection spans more than one format, name one:

```bash
pnpm better-supabase spec emit openapi --spec-version 3.2
pnpm better-supabase spec emit openapi --spec-version 3.0 --out legacy/openapi.json
```

An unknown format or version, a bad `--fail-on` or `--ui`, `--watch` with
another action than `emit`, and an `--interval` that is not a positive
number exit with code 2.

## Findings and `--fail-on` [#findings-and---fail-on]

Every output reports the diagnostics of its render: the model's, the
format's own checks (see [diagnostics](/docs/specs/diagnostics)) and the
ones `spec` adds. They print grouped by severity, each with its output
path, code and JSON Pointer:

| `--fail-on` | Fails on                                                |
| ----------- | ------------------------------------------------------- |
| `error`     | errors                                                  |
| `warning`   | errors and warnings                                     |
| `never`     | no finding; a stale or missing file still fails `check` |

The codes `spec` adds:

| Code                 | Severity | Meaning                                                                                  |
| -------------------- | -------- | ---------------------------------------------------------------------------------------- |
| `output-stale`       | error    | `check`: the file on disk differs from the rendered document                             |
| `output-missing`     | error    | `check`: the file does not exist yet                                                     |
| `schema-invalid`     | error    | `validate`: the document does not match the format's official JSON Schema                |
| `schema-unavailable` | info     | `validate`: no official schema is bundled for this version; only the semantic checks ran |
| `overlay-invalid`    | error    | An overlay file does not parse or is not an Overlay document                             |
| `document-version`   | error    | The entry exports a finished document in another version than the one asked for          |
| `lint-failed`        | warning  | The format's lint threw on a finished document the entry exports                         |
| `ui-unsupported`     | info     | `ui` is set on an output that is not OpenAPI                                             |

`validate` checks against the official schemas the CLI bundles: OpenAPI
3.0, 3.1 and 3.2, AsyncAPI 3.0 and 3.1, and Arazzo 1.0 and 1.1. The OpenAPI
`3.3-preview` and formats an entry adds get `schema-unavailable`. It reports
at most 50 schema errors per document, then one line with the count of the
rest. The validator and the schemas load only for `validate`.

## The cache [#the-cache]

Rendering a large model takes time, so `spec` keeps each render under
`node_modules/.cache/better-supabase/specs`, one file per output path. The
key is a SHA-256 of the CLI version, the format, the version and its pin,
the serialization, the model (as canonical JSON), the overlay files' text
and the entry's `cacheKey`. A hit prints `cached` next to the output, and a
hit from `validate` keeps its schema findings.

A model that can't be serialized (for example one that holds functions) is
never cached. Export a `cacheKey` from the entry when the document depends
on something the model doesn't show. `--no-cache` renders every output
again. A read-only `node_modules` only costs the next run a render.

## Watch mode [#watch-mode]

```bash
pnpm better-supabase spec emit --watch
```

`--watch` runs `emit`, then checks every `--interval` milliseconds whether
the config file, the entry module, the generated module (`output`) or an
overlay file changed, and runs again when one did. A changed config file is
loaded again first. A failed run is retried on the next change, and the same
error prints once. Files the entry imports are not watched, apart from the
generated module.

## The manifest [#the-manifest]

Every run writes a diagnostics manifest to
`node_modules/.cache/better-supabase/specs/diagnostics.json`,
`--manifest <file>` writes a copy, and `--json` prints it on stdout. It follows
[`schemas/spec-manifest-v1.json`](https://unpkg.com/better-supabase/schemas/spec-manifest-v1.json)
and has no timestamps, so the same inputs give the same bytes:

```json title="spec-manifest.json"
{
  "$schema": "https://unpkg.com/better-supabase/schemas/spec-manifest-v1.json",
  "version": 1,
  "generator": "better-supabase 0.7.1",
  "failOn": "error",
  "ok": true,
  "outputs": [
    {
      "format": "openapi",
      "version": "3.1",
      "pin": "3.1.2",
      "path": "openapi.json",
      "sha256": "9f2c…",
      "status": "unchanged",
      "cached": true,
      "diagnostics": []
    }
  ],
  "summary": { "error": 0, "warning": 0, "info": 0 }
}
```

| Field     | What it holds                                                                                                                                   |
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `ok`      | `false` when a finding fails `failOn` or `check` found a stale file                                                                             |
| `outputs` | Per output: format, version, the exact `pin` it follows, path, SHA-256 of the text, status, whether it came from the cache, and its diagnostics |
| `status`  | `written` or `unchanged` (`emit`), `up-to-date` or `stale` (`check`), `validated` (`validate`)                                                  |
| `summary` | The number of findings per severity                                                                                                             |

## Reference pages [#reference-pages]

`ui` on an output, or `--ui <name>`, also writes an HTML reference page next
to each OpenAPI document: `openapi.html` for `openapi.json`. The page loads
the document from `./openapi.json` and takes its title from `info.title`.
`check` compares the page too. The UIs, their pinned versions and the
self-hosted mode are on [Reference UIs](/docs/specs/reference-ui).

## In CI [#in-ci]

```bash
pnpm better-supabase spec check --fail-on warning
pnpm better-supabase spec validate --manifest spec-manifest.json
```

`check` exits 1 when a file is stale or missing, or when a finding fails
`--fail-on`. Usage errors exit 2.

# better-result

> Return better-result values from repositories with withBetterResult, or convert single results at the boundary.

Source: https://bettersupabase.com/docs/concepts/better-result

If your services return [better-result](https://github.com/dmmulroy/better-result)
values, wrap the `db` once with `withBetterResult` from
`better-supabase/better-result`, or convert single results with
`toBetterResult`. better-result is an optional peer (`>=3 <4`); the subpath
imports only its types, and you pass its `Result` namespace in.

## withBetterResult [#withbetterresult]

`withBetterResult(db, toResult)` returns a view of `db` whose repository
methods, `$rpc`, `$run`, `$many`, `$search` and list queries return
`Promise<Result<T, E>>` from better-result. `toResult` is a converter from
`defineBetterResultErrors` (see [Tagged errors](#tagged-errors)) or
`{ Result, mapError }`:

```ts title="src/lib/db.ts"
import { withBetterResult } from "better-supabase/better-result";

import { toResult } from "./errors";

export const rdb = withBetterResult(db, toResult);
```

```ts
const customer = await rdb.customers.findById(id, { select: ["id", "name"] });
// Result<{ id: string; name: string }, NotFound | Conflict | DbFailure>

const page = await rdb.$list(customersList, query); // like customersList.run(db, query)
const counts = await rdb.$rpc("customer_note_counts", { p_customer_ids: ids });
```

The rows keep the types `select` and `include` give them. `db` itself is
unchanged, and `rdb.$db` returns it. For a `db` with more context, wrap
`db.$with(context)`. Plugin methods that return an `AsyncResult` are
converted too; a generic plugin method loses its type parameters, so call it
on `rdb.$db` and pass the result to `rdb.$from(result)`, which converts any
`AsyncResult`.

With `{ Result, mapError }`, the error type is what `mapError` returns:

```ts
import { Result } from "better-result";

const rdb = withBetterResult(db, { Result, mapError: toAppError });
```

## Convert single results [#convert-single-results]

`toBetterResult` converts one `Result` or `AsyncResult`, and
`fromBetterResult` converts back. You pass better-result's `Result`
namespace, so better-supabase doesn't depend on it:

```ts
import { Result } from "better-result";
import { fromBetterResult, toBetterResult } from "better-supabase";

// Result<T> -> better-result, with your error type
const found: Result<Customer, AppError> = toBetterResult(
  await db.customers.findById(id),
  Result,
  toAppError,
);
const name = found.map((customer) => customer.name);

// An AsyncResult works too, and awaits it
async function listCustomers(): Promise<Result<Customer[], AppError>> {
  return toBetterResult(db.customers.findMany(), Result, toAppError);
}

// And back: DbErrors pass through, other errors become `unexpected`
const result = fromBetterResult(Result.ok(42)); // Result<number>
```

`toBetterResult` returns the real `Ok` or `Err`. When the call has an
expected type, such as a variable annotation or the function's return
type, the value has that better-result type with all its methods, and no
cast is needed. The expected type is checked: a row type that differs
from the query's, or an error type that `mapError` does not return, is a
type error. Pass the type as a type argument when there is no expected
type: `toBetterResult<Result<Customer, AppError>>(row, Result, toAppError)`.
Without either, the value is typed by its `status`, `value` and `error`
fields (`BetterResultValue<T, E>`).

Without the third argument, a result from a `db` of `betterSupabase.mapError(fn)`
goes through `fn`, and its error is typed `unknown`; pass the mapper to
type it. `fromBetterResult` reads the `status` field, so it works across
better-result versions; pass a second argument to map non-`DbError`
errors yourself.

## Tagged errors [#tagged-errors]

To get better-result `TaggedError` classes instead of `DbError` objects,
map the kinds once with `defineBetterResultErrors`. It takes the `Result`
namespace, a class per `DbError` kind you care about, and a fallback class
for the rest, and returns a converter with the same expected-type checks
as `toBetterResult`:

```ts title="src/lib/errors.ts"
import { Result, TaggedError } from "better-result";
import { type DbError, defineBetterResultErrors } from "better-supabase";

type DbProps = { message: string; error: DbError };
export class NotFound extends TaggedError("NotFound")<DbProps> {}
export class Conflict extends TaggedError("Conflict")<DbProps> {}
export class DbFailure extends TaggedError("DbFailure")<DbProps> {}
export type AppDbError = NotFound | Conflict | DbFailure;

export const toResult = defineBetterResultErrors(
  Result,
  { not_found: NotFound, conflict: Conflict },
  DbFailure,
);
```

```ts
async function customer(id: string): Promise<Result<Customer, AppDbError>> {
  return toResult(db.customers.findById(id));
}

const message = (await customer(id)).match({
  ok: (row) => row.name,
  err: (error) => error.message,
});
```

Each instance gets the error's `message` and the original `DbError` as
`error`, so a class can declare either or both. `toResult.map(error)` maps
one `DbError` and is typed by its kind: a `DbErrorOf<"not_found">` becomes a
`NotFound`. An expected error type that leaves out one of the classes is a
type error, so adding a kind to the mapping shows every place that has to
handle it.

# Caching

> How reads know which tables they touched, and how writes invalidate exactly those reads.

Source: https://bettersupabase.com/docs/concepts/caching

better-supabase never patches cached rows. Every read records the tables it
touched, every write reports the tables it changed, and caches refetch the
reads that overlap. It's simpler than normalized caching and stays correct
with RLS, includes, relation filters and cascading deletes, because the
database does the work again instead of the client guessing.

## Reads: touched tables [#reads-touched-tables]

A read touches its table, every table it `include`s (nested includes too,
and `_count` includes), and every table named in a relation filter:

```ts
db.customers.findMany({
  where: { notes: { some: { kind: "call" } } },
  include: { organization: true, _count: { locations: true } },
});
// touches customers, notes, organizations, locations
```

`betterSupabase.tablesOf(spec)` returns that list for a [query spec](#query-specs), and
`touchedTables(op)` returns it for a built operation.

## Writes: invalidation targets [#writes-invalidation-targets]

A mutation invalidates its own table. A delete also invalidates tables whose
rows change because of it: foreign keys with `on delete cascade`, `set null`
or `set default`, followed through further cascades. `gen` records those
actions from the database, so there's nothing to configure.

```ts
invalidationTargets(betterSupabase.meta, "customers");
// ['customers', 'contacts', 'locations', 'notes', 'customerTags', ...]
```

Database functions don't say what they change. Declare it once:

```ts
export const betterSupabase = defineSupabase(schema).defineRpc(
  "archive_customer",
  {
    invalidates: ["customers", "notes"],
  },
);
```

`db.$rpc('archive_customer', ...)` then invalidates those tables like any
mutation.

## Cache adapters [#cache-adapters]

`betterSupabase.cache(adapter)` subscribes a [`CacheAdapter`](/docs/extending/interfaces#cacheadapter)
to every mutation and declared RPC. Each call gets a `CacheTarget` with the
table, the changed row ids, and `tables`, the full list to invalidate.

| Adapter                                                | What it invalidates                                                                 |
| ------------------------------------------------------ | ----------------------------------------------------------------------------------- |
| `queryCache(queryClient)` from `better-supabase/query` | TanStack queries whose `meta.bsTables` overlaps `tables`                            |
| `nextCache()` from `better-supabase/next`              | `tagFor(table)` for each table and `tagFor(table, id)` per row, via `revalidateTag` |

Your own adapter implements one method, and `testCacheAdapter` from
`better-supabase/testing` checks it against the same cases.

## TanStack Query [#tanstack-query]

Query options from `createQueries` carry `meta: { bsTables }`. Invalidation
is a predicate over that meta, so a write to `notes` refetches a
`customers.findMany` that included notes, and nothing else. Keys stay
`['bs', table, method, ...args]` for devtools and manual invalidation. See
[TanStack Query](/docs/frontend/query).

## Next.js [#nextjs]

In a cached server component, tag the data with what it touched:

```ts
"use cache";
const spec = betterSupabase.spec.customers.findMany({
  include: { notes: true },
});
bs.cacheTags(spec); // tags customers and notes
const customers = await db.$run(spec).orThrow();
```

Server actions and route handlers that write through `db` revalidate those
tags automatically (`cacheTags: false` turns it off).

`bs.cacheTags` also takes an array of specs or a
[read set](/docs/repository/read-sets), and tags the union of the tables
they read.

## Query specs [#query-specs]

A `QuerySpec` is a read described as plain JSON:
`{ v: 1, table, method, args }`. `betterSupabase.spec.<table>.<method>(...)` builds one
with full types, `db.$run(spec)` runs it, and `InferResult<typeof spec>` is
its result type. Because it serializes, the same spec can be built on the
server, sent to the client, used as a query key and passed to
[`useLiveQuery`](/docs/frontend/live-queries).

```ts
const spec = betterSupabase.spec.customers.findMany({
  select: ["id", "name"],
  limit: 20,
});
type Rows = InferResult<typeof spec>; // { id: string; name: string }[]
await queryClient.prefetchQuery(q.$spec(spec));
```

## Live updates [#live-updates]

[Live queries](/docs/frontend/live-queries) reuse the same model: a trigger
broadcasts "table changed" (no row data) and the client invalidates the
queries that touched that table, which then refetch through RLS.

# Casing

> snake_case or camelCase models, mapped at the edge of the database.

Source: https://bettersupabase.com/docs/concepts/casing

Set `casing` in the config. A config without it uses `'snake'`, which keeps
database names as they are; `better-supabase init` writes `casing: "camel"`
unless you pass `--casing snake`.

```ts
export default defineConfig({ casing: "camel" });
```

With `'camel'`, models, filters, includes and returned rows use camelCase.
The mapping happens in the query itself, not in a post-processing pass:

```ts
await db.customers.findMany({ select: ["id", "organizationId"] });
// GET /customers?select=id,organizationId:organization_id
```

PostgREST renames the columns in its response, so rows need no transform and
nested includes keep their shape.

## Per-table overrides [#per-table-overrides]

```ts
export default defineConfig({
  casing: "camel",
  tables: { legacy_events: { casing: "snake" } },
});
```

## Function results [#function-results]

`db.$rpc` returns rows of a table (`returns setof customers`) and records
(`returns table (customer_id uuid, total numeric)`) in the configured casing,
with the same codecs as reads. `gen` records which functions need it in the
metadata, so other results, such as a scalar, `json` or `jsonb`, come back
as Postgres sent them:

```ts
const rows = await db
  .$rpc("customers_by_status", { p_status: "lead" })
  .orThrow();
rows[0].organizationId;
```

`returns` validates the decoded value, so a schema for the cased row works.
`{ raw: true }` skips the decoding and returns database names, typed as
`unknown`. PostgREST sends `int8` as a JSON number in function results, so
values above 2^53 lose precision before a `bigint` codec sees them; cast
them to `text` in the function when they can be that large.

## What stays in database names [#what-stays-in-database-names]

* Constraint names (`onConflict: 'customers_organization_id_kvk_key'`).
* Plugin config columns (`softDelete: { column: 'archived_at' }`): these name
  database columns, and the generated flags hold the app names.
* Raw SQL, RPC argument names and the keys inside `json` results.

Error details keep the database name in `constraint` and `columns`, since
they come from Postgres.

# Schema and codegen

> What better-supabase generates on top of supabase gen types, and why.

Source: https://bettersupabase.com/docs/concepts

`supabase gen types` gives you row shapes. It leaves out what a typed query
layer needs:

| Missing piece            | What better-supabase generates                           |
| ------------------------ | -------------------------------------------------------- |
| Relationship cardinality | `Relations` with `kind: 'one' \| 'many'` and nullability |
| Unique keys              | `UniqueKeys`, used by typed `upsert({ onConflict })`     |
| CHECK unions             | `status: 'lead' \| 'active'` instead of `string`         |
| jsonb shapes             | Types from `json` in your config                         |
| App casing               | Column maps for `camelCase` models                       |
| Table flags              | Soft delete, timestamps, tenant and actor columns        |

The CLI reads `pg_catalog` directly, so the metadata always matches the
database, including views, composite keys and reverse relations.

## The generated module [#the-generated-module]

```ts title="generated.ts (excerpt)"
export const customersStatusValues = ["lead", "active", "archived"] as const;
export type CustomersStatus = (typeof customersStatusValues)[number];

export type Models = {
  customers: {
    Row: {
      id: string;
      status: CustomersStatus;
      primaryContactId: string | null; /* ... */
    };
    Insert: { id?: string; name: string /* ... */ };
    Update: { name?: string /* ... */ };
    Relations: {
      organization: { table: "organizations"; kind: "one"; nullable: false };
      primaryContact: { table: "contacts"; kind: "one"; nullable: true };
      notes: { table: "notes"; kind: "many"; nullable: true };
    };
    PrimaryKey: "id";
    UniqueKeys: {
      customers_organization_id_kvk_key: ["organizationId", "kvk"];
    };
    Flags: { softDelete: "archivedAt"; timestamps: true };
  };
};

export type RowOf<T extends TableName> = Models[T]["Row"];
export const schema: Schema<Models, Database, Functions> = defineSchema({
  /* runtime metadata */
});
```

Everything the runtime needs (column maps, foreign key names used as embed
hints, primary keys) lives in the `schema` object. Types and metadata come
from the same run, so they cannot drift.

## Relation names [#relation-names]

* A forward relation (this table holds the foreign key) is named after the
  column without `_id`: `primary_contact_id` becomes `primaryContact`.
* A reverse relation is named after the source table: `notes`. One-to-one
  reverse relations use the singular.
* Collisions get a `_by_<column>` suffix. A tenant column shared by both
  sides of a composite key is left out of it, so
  `(customer_id, organization_id)` gives `customerByCustomer`. When two keys
  still get the same name, such as `(customer_id)` and
  `(customer_id, organization_id)` to the same table, the suffix lists every
  key column (`customerByCustomerOrganization`), then the constraint name.
* Rename any relation in the config:
  `tables: { customers: { relations: { primaryContact: 'contact' } } }`.

## Validators [#validators]

Generators turn the same metadata into validators and documents:

```ts
import { defineConfig, jsonSchema, valibot, zod } from "better-supabase/config";

export default defineConfig({ generators: [zod(), valibot(), jsonSchema()] });
```

`zod()` writes `generated.zod.ts` with `customersRow`, `customersInsert`
and `customersUpdate`. Each is annotated with the generated type
(`z.ZodType<InsertOf<'customers'>>`), so a validator that drifts from the
table fails to compile. Generated identity columns are left out of inserts,
CHECK unions become `z.enum`, and typed jsonb columns can use your own schema:

```ts
zod({ json: { "customers.metadata": "./src/schemas.ts#customerMetadata" } });
```

All generated validators implement [Standard Schema](/docs/standards), so the
validation plugin, forms and MCP inputs accept them as they are.

`zod({ flavor: "mini" })` writes the same validators against `zod/mini`
(`z.nullable(...)`, `.check(z.gte(...))`, `.check(z.meta(...))`), typed as
`z.ZodMiniType`, for bundles where size matters. Both flavors need zod 4;
Zod 3 is not supported.

`jsonSchema({ target })` picks the JSON Schema dialect of the document it
writes:

| `target`                  | Output                                                                 |
| ------------------------- | ---------------------------------------------------------------------- |
| `draft-2020-12` (default) | `$schema` and `$defs`                                                  |
| `draft-07`                | `definitions`, for validators and tools that stop at draft-07          |
| `openapi-3.0`             | `definitions` with `nullable` and `example`, for OpenAPI 3.0 documents |

Table and column comments become descriptions in every generator (see
[documentation in the validators](/docs/cli/gen#documentation-in-the-validators)).
A comment line that starts with `@deprecated` marks the table or column
deprecated, so the generated JSON Schema and `defineApi` documents carry
`deprecated: true`.

# Naming

> The names better-supabase uses for definitions, instances, files and types, and the renames from earlier versions.

Source: https://bettersupabase.com/docs/concepts/naming

Every app has one definition and one runtime instance per side. The docs,
the examples and the files `better-supabase init` writes all use the same
names, so code copied from one place works in another.

## Definition and instances [#definition-and-instances]

`betterSupabase` is the definition from `defineSupabase`. It holds the schema
and the configuration, no secrets, and is safe to import anywhere.

`bs` is the runtime instance an adapter creates from it: `createNext`,
`createServer`, `createClient`, `createHono`, `createOrpc`, `createEdge` or
`createMcp`. Each file that creates one exports it as `bs`, so a call reads
the same in every framework:

```ts
const { db } = await bs.context(); // Next.js Server Component
app.use("/api/*", bs.middleware()); // Hono
Deno.serve(bs.handler(fn)); // Edge Function
```

## Files [#files]

The definition and the instances live in `lib/supabase/`, the layout
Supabase uses for its own `utils/supabase/server.ts` and `client.ts`:

| File                        | Exports          | Runs on                                         |
| --------------------------- | ---------------- | ----------------------------------------------- |
| `lib/supabase/index.ts`     | `betterSupabase` | both, imported as `@/lib/supabase`              |
| `lib/supabase/server.ts`    | `bs`             | the server; Next.js adds `import "server-only"` |
| `lib/supabase/client.ts`    | `bs`             | the browser                                     |
| `lib/supabase/generated.ts` | `schema`         | both, written by `better-supabase gen`          |

Hono and oRPC starters create `bs` in the app's entry (`src/server.ts`,
`src/router.ts`), which only runs on the server. Edge Functions import the
definition from `supabase/functions/_shared/supabase.ts` and create `bs` in
the function's own `server.ts`.

## Types [#types]

* The adapter factories return `Better<Adapter>`: `createNext` returns
  `BetterNext`, `createClient` returns `BetterClient`, `createPostgres`
  returns `BetterPostgres`, `createQueries` returns `BetterQueries`.
* Options are `<Thing>Options`: `ClientOptions`, `SupabaseOptions`,
  `QueriesOptions`, `MiddlewareOptions`.
* Acronyms in names are written as words: `toOrpcError`, `createOpenApi`.
* Members that sit next to your table names start with `$` so a table can't
  shadow them: `db.$table(name)`, `repository.$tableName`, `queries.$key`.

Supabase's own cookie and header names keep their `sb-` prefix
(`sb-<project>-auth-token`); those belong to `@supabase/ssr`, not to the
library.

## Renames [#renames]

These names changed in 0.4. There are no aliases:
[`better-supabase codemod 0.4`](/docs/cli/codemod) renames the imports,
members and the provider prop, lists the lines it can't rename safely, and the
compiler points out anything left.

| Before                                           | After                                           |
| ------------------------------------------------ | ----------------------------------------------- |
| `const sb = defineSupabase(schema)`              | `const betterSupabase = defineSupabase(schema)` |
| `const next = createNext(sb)`                    | `const bs = createNext(betterSupabase)`         |
| `const browser = createBrowser(sb)`              | `const bs = createClient(betterSupabase)`       |
| `src/lib/supabase.ts`                            | `src/lib/supabase/index.ts`                     |
| `src/lib/supabase.server.ts`                     | `src/lib/supabase/server.ts`                    |
| `src/lib/supabase.browser.ts`                    | `src/lib/supabase/client.ts`                    |
| `next.server()`                                  | `bs.context()`                                  |
| `next.serverFor(session, { token })`             | `bs.contextForSession(session, { token })`      |
| `BetterBrowser`, `BrowserOptions`, `BrowserAuth` | `BetterClient`, `ClientOptions`, `ClientAuth`   |
| `<BetterSupabaseProvider browser={browser}>`     | `<BetterSupabaseProvider client={bs}>`          |
| `client.sb`                                      | `client.betterSupabase`                         |
| `BetterEnv` (Hono)                               | `HonoEnv`                                       |
| `bs.handle()` (Hono)                             | `bs.handler()`                                  |
| `bs.toORPCError(error)` (oRPC)                   | `bs.toOrpcError(error)`                         |
| `HandlerOptions` (Edge)                          | `MiddlewareOptions`                             |
| `mcp.handler`                                    | `bs.endpoint`                                   |
| `Queries`, `CreateQueriesOptions`                | `BetterQueries`, `QueriesOptions`               |
| `queries.key`                                    | `queries.$key`                                  |
| `Postgres`                                       | `BetterPostgres`                                |
| `DefineSupabaseOptions`                          | `SupabaseOptions`                               |
| `repository.$table`                              | `repository.$tableName`                         |
| `{ sb }` in `testExecutor` and the test plugin   | `{ betterSupabase }`                            |

`createHono`, `createOrpc`, `createEdge` and `createMcp` now keep the claims
and profile types from `.claims()` and `.userMetadata()`, so `auth.claims`
is typed in those adapters too. Code that parsed the claims again to read a
role can drop the parse.

# Results and errors

> Every call returns a Result with a typed, serializable DbError.

Source: https://bettersupabase.com/docs/concepts/results

Repository methods never throw for database errors. They return an
`AsyncResult`, which you can await for a `Result` or unwrap:

```ts
const result = await db.customers.findById(id);
if (!result.ok) {
  if (result.error.kind === "not_found") return notFound();
  throw result.error;
}
result.data.name;

// Or throw a DbException on error:
const customer = await db.customers.findById(id).orThrow();

// Or chain:
const name = await db.customers
  .findById(id)
  .map((row) => row.name)
  .unwrapOr("Unknown");
```

`AsyncResult` has `orThrow`, `unwrapOr`, `map`, `mapError` and `andThen`.
`mapError` here rewrites the `DbError` inside one result; `betterSupabase.mapError()`
([below](#domain-errors-for-orthrow)) changes what `.orThrow()` throws.

## Error kinds [#error-kinds]

`DbError` is plain data, safe to return from server actions and RPC handlers.

| kind              | status | From                                                                                                                                                                                                                      |
| ----------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `not_found`       | 404    | `findById`, `update`, `delete` with no row, `PGRST116`, `P0002` (`no_data_found`)                                                                                                                                         |
| `unauthorized`    | 401    | Missing or expired JWT                                                                                                                                                                                                    |
| `forbidden`       | 403    | `42501`, RLS `WITH CHECK` failures; guards with `aal` set `required` ([MFA](/docs/auth/mfa-sso)); an [authorizer](/docs/extending/authorizers) refusal sets `permission`, and `approval` with `code: "APPROVAL_REQUIRED"` |
| `conflict`        | 409    | `23505` unique violation, with `constraint` and `columns`                                                                                                                                                                 |
| `foreign_key`     | 409    | `23503`                                                                                                                                                                                                                   |
| `check`           | 422    | `23514`, with `constraint`; a `jsonb-schemas` check is `validation` instead                                                                                                                                               |
| `not_null`        | 422    | `23502`, with `column`                                                                                                                                                                                                    |
| `exclusion`       | 409    | `23P01`                                                                                                                                                                                                                   |
| `invalid_input`   | 400    | `22*` data exceptions                                                                                                                                                                                                     |
| `invalid_value`   | 500    | A stored value the app type can't hold, such as `infinity` in a Temporal timestamp, with `column`                                                                                                                         |
| `raised`          | 400    | `RAISE EXCEPTION` (`P0001`); `hint` carries your app code                                                                                                                                                                 |
| `timeout`         | 504    | `57014` statement timeout, or a call past its `timeout` option                                                                                                                                                            |
| `serialization`   | 409    | `40001`, safe to retry                                                                                                                                                                                                    |
| `network`         | 503    | Fetch failures, `08*`                                                                                                                                                                                                     |
| `aborted`         | 499    | The `AbortSignal` fired                                                                                                                                                                                                   |
| `invalid_request` | 400    | A query the backend cannot express                                                                                                                                                                                        |
| `validation`      | 422    | Standard Schema issues from the validation plugin, or the [jsonb-schemas](/docs/blocks/sql#enforcing-jsonb-shapes) errors                                                                                                 |
| `multiple_rows`   | 409    | A single-row call matched several rows                                                                                                                                                                                    |
| `stale`           | 412    | `update(..., { expect })` found a newer row                                                                                                                                                                               |
| `max_affected`    | 400    | A write matched more rows than its `maxAffected`, `PGRST124`; `maxAffected` is the limit                                                                                                                                  |
| `rate_limited`    | 429    | Over a [write rate limit](/docs/blocks/sql#rate-limiting-writes); `retryAfter` is the seconds to wait                                                                                                                     |
| `quota_exceeded`  | 429    | A [usage quota](/docs/blocks/usage) is used up; `meter`, `limit` and `retryAfter` (seconds until the period resets)                                                                                                       |
| `unsupported`     | 501    | The executor can't run the operation, such as an include on [SQLite](/docs/repository/powersync)                                                                                                                          |
| `unexpected`      | 500    | Anything else                                                                                                                                                                                                             |

The `status` field feeds the [RFC 9457](/docs/standards) problem details
mapping in the server adapters.

## Custom kinds and mappers [#custom-kinds-and-mappers]

Add mappers for your own `RAISE` codes:

```ts
const betterSupabase = defineSupabase(schema, {
  errors: [
    (raw, fallback) =>
      raw.hint === "quota_exceeded"
        ? dbError("forbidden", fallback.message)
        : undefined,
  ],
});
```

Plugins can also contribute a `mapError`. These mappers turn a raw
Postgres or PostgREST error into a `DbError`; the next section turns a
`DbError` into your own error.

## Domain errors for `.orThrow()` [#domain-errors-for-orthrow]

`betterSupabase.mapError(fn)` sets the error `.orThrow()` throws for every `db` it
connects, instead of a `DbException`:

```ts
export class AppError extends Error {
  constructor(readonly error: DbError) {
    super(error.message, { cause: error });
  }
}

export const toAppError = (error: DbError) => new AppError(error);
export const betterSupabase = defineSupabase(schema).mapError(toAppError);

await db.customers.findById(id).orThrow(); // throws AppError
```

Results themselves keep their `DbError`, so they stay serializable. The
mapper survives `map`, `mapError` and `andThen`; a factory passed to
`.orThrow(factory)` takes precedence.

Set the `DbError` as the error's `cause`. The server, Next.js, Hono and
oRPC adapters then still answer with Problem Details, and `isConflict`,
`isCheck` and `isForeignKey` still match. TanStack Query hooks always see a
`DbException`, whatever the mapper.

## better-result [#better-result]

To return [better-result](https://github.com/dmmulroy/better-result) values
instead, wrap the `db` with `withBetterResult` or convert single results with
`toBetterResult`. See [better-result](/docs/concepts/better-result).

# Temporal

> Instants, durations and the clock as Temporal values, and the polyfill for runtimes without it.

Source: https://bettersupabase.com/docs/concepts/temporal

Every time value better-supabase hands you or accepts is a `Temporal` value
instead of a `Date`. An instant is a `Temporal.Instant`, a wall-clock
`timestamp` column is a `Temporal.PlainDateTime`, and a length of time is a
`Temporal.Duration`.

## Pass Temporal to defineSupabase [#pass-temporal-to-definesupabase]

Node 26 and current Chrome and Firefox ship `Temporal`. On Node 24, Safari,
Hermes and any other runtime without it, install the polyfill and pass its
namespace to `defineSupabase`:

```bash
pnpm add temporal-polyfill
```

```ts title="src/lib/supabase/runtime.ts"
import { Temporal } from "temporal-polyfill";
import { defineSupabase } from "better-supabase";

import { schema } from "./generated";

export const betterSupabase = defineSupabase(schema, { temporal: Temporal });
```

Every part of better-supabase then reads `Temporal` from that option: decoded
rows, the default clock, plugins, jobs, webhooks and storage. `globalThis`
stays untouched, and `betterSupabase.temporal` returns the namespace in use.
better-supabase never imports the polyfill itself. When a call needs
`Temporal` and none is available, the call returns a `DbError` with kind
`unexpected` and the message names the option.

The types come from TypeScript's `esnext.temporal` lib, which the published
declarations reference. TypeScript 6 and 7 include it; 5.9 does not.

## Or install the global polyfill [#or-install-the-global-polyfill]

When you would rather patch `globalThis`, for example because your own code
uses `Temporal` without importing it, import the global entry once, before the
first query, and leave the `temporal` option out:

```ts title="src/instrumentation.ts"
import "temporal-polyfill/global";
```

`temporal-polyfill/global` installs only when the runtime has no native
`Temporal`, so the import is safe to keep after you upgrade.

## One namespace per process [#one-namespace-per-process]

The option also sets one process-wide namespace for code that runs without a
definition. Pass the same namespace to every definition in a process: when a
second definition passes a different one, better-supabase logs a warning and
the newer one wins for decoding. Each definition's default clock always uses
its own `temporal` option.

Helpers you call without a definition, such as `verifyWebhook` in a separate
worker, read the same setting. Call `provideTemporal(Temporal)` from
`better-supabase` once at startup in that process.

Every block creator also takes `temporal`, so a server that only uses blocks
needs no definition and no global polyfill:

```ts
import { Temporal } from "temporal-polyfill";
import { createAuditLog } from "better-supabase/blocks/audit";

const audit = createAuditLog({ transport, temporal: Temporal });
```

That covers the `create*` functions, `createJobs(source, queues, { temporal })`,
`createRateLimit(sql, { temporal })`, `exportAuditLog`, `purgeAuditLog`,
`scimHandler`, and the `connect` options of settings and onboarding
checklists. Like `defineSupabase`, the option sets the process-wide
namespace.

The Valibot and Zod schemas that `better-supabase gen` writes never read
`Temporal` when the module loads. They check values with `isInstant` and
`isPlainDateTime`, which compare `Symbol.toStringTag`, so a value from any
copy of Temporal passes, and the file imports cleanly before the namespace
exists.

### React Native and Hermes [#react-native-and-hermes]

Hermes has no `Temporal`. In an Expo or React Native app, pass the module
namespace as above instead of importing `temporal-polyfill/global`, so the
native bundle and the server render use the same code path. See
[React Native](/docs/frontend/react-native).

## Where Temporal appears [#where-temporal-appears]

| API                                            | Temporal type                                                                                 |
| ---------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `codecs.timestamptz: 'instant'`                | `timestamptz` rows decode to `Temporal.Instant`, `timestamp` rows to `Temporal.PlainDateTime` |
| `defineSupabase({ now })`, plugin hooks        | `now: () => Temporal.Instant`, also the `time` of block events on `betterSupabase.events`     |
| `defineSupabase({ temporal })`                 | the `Temporal` namespace every other API reads                                                |
| `Job`, `EnqueueOptions`, `WebhookInboxMessage` | `enqueuedAt`, `visibleUntil`, `runAt`, `receivedAt`                                           |
| `createJobs(source, queues, { now })`          | `now`, the clock for `runAt` delays and schedule runs                                         |
| `verifyWebhook`, `signWebhook`                 | `timestamp` and `now`                                                                         |
| `bucket.sweep`                                 | `olderThan: Temporal.Instant` or `Temporal.Duration`                                          |
| `toCloudEvents`, `forwardMutations`            | `now`                                                                                         |

Auth keeps `now: () => number` in epoch milliseconds, the unit JWT `exp` and
`iat` math uses.

## Decoded columns [#decoded-columns]

With `codecs: { timestamptz: 'instant' }`, the generated select casts the
column to `text` so Postgres sends its full microsecond value, and the
repository parses it with `Temporal.Instant.from`. Filters and writes accept
the same values back:

```ts
const since = Temporal.Now.instant().subtract({ hours: 24 });
await db.orders.findMany({ where: { createdAt: { gte: since } } });
```

Without the codec, `timestamptz` columns stay ISO strings, as PostgREST
returns them.

## Durations [#durations]

`Temporal.Instant` arithmetic has no calendar, so a `Duration` passed to
`sweep` may use days, hours, minutes and smaller units. A day counts as 24
hours. Months and years need a calendar; compute the cutoff instant yourself:

```ts
const cutoff = Temporal.Now.zonedDateTimeISO("Europe/Amsterdam")
  .subtract({ months: 3 })
  .toInstant();
await uploads.sweep({ olderThan: cutoff, referenced });
```

## Tests [#tests]

With the polyfill, Vitest fake timers drive `Temporal.Now` as well as `Date`,
because the polyfill reads the clock through `Date.now`:

```ts
vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00Z") });
```

`toEqual` compares Temporal objects by their own enumerable keys, and they have
none, so two different instants compare equal. Compare with `.equals()` or
`String(value)`, or register an equality tester in a setup file:

```ts title="tests/setup.ts"
import "temporal-polyfill/global";
import { expect } from "vitest";

expect.addEqualityTesters([
  (a, b) =>
    a instanceof Temporal.Instant && b instanceof Temporal.Instant
      ? a.equals(b)
      : undefined,
]);
```

# eve on Supabase

> Run eve agents on Supabase with the Workflow World on Postgres, Supabase sign-in on eve routes, connections over credential providers, memory, sessions in the chat tables and the inbox as a channel.

Source: https://bettersupabase.com/docs/eve

`better-supabase/eve` connects [eve](https://eve.dev/docs) agents to the
blocks you already run. It adds no tables of its own: sessions land in the
[AI chat](/docs/blocks/ai-chat) tables, memory in the
[memory](/docs/blocks/memory) and [knowledge](/docs/blocks/knowledge)
blocks, tokens in a [credential provider](/docs/extending/credentials), and the
durable runs in the [Supabase World](/docs/blocks/workflow-sdk).

```bash
pnpm add eve @workflow/world @workflow/world-postgres pg
pnpm better-supabase sql add workflow-sdk-world ai-chat memory knowledge credentials inbox
```

`eve` is an optional peer (`>=0.71 <1`). The entry types eve structurally
and imports nothing from it at load time; the memory provider loads
`eve/tools` the first time it builds its tools. eve runs on Node, so the
entry is Node-only, like the World. The inbox channel also needs `chat`
(see [Chat SDK](/docs/chat-sdk)).

| Export                    | Plugs into                                      | Does                                                                        |
| ------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------- |
| `supabaseAuth`            | `eveChannel({ auth })` and other channel `auth` | verifies the Supabase session locally and maps the user to an eve principal |
| `credentialAuth`          | `defineMcpClientConnection({ auth })`           | reads connection tokens from Vault, Vercel Connect or your provider         |
| `supabaseMemory`          | `defineMemory({ provider })`                    | recalls core and archival memory and tenant documents per turn              |
| `supabaseDocumentBackend` | `fileMemory({ backend })`                       | stores eve's file memory in the memory module's `memory_documents` table    |
| `persistSessions`         | `defineHook({ events })`                        | copies each session into `ai_chats`, `ai_messages` and `ai_runs`            |
| `routeInbox`              | `chatSdkChannel()` over `inboxAdapter`          | hands inbox conversations in bot mode to eve, with the contact as principal |

The [eve example](/docs/examples) wires all of them into a Next.js app.

## The World [#the-world]

eve runs every session as a Workflow SDK run. Select the Supabase World in
the root `agent.ts`; the World reads its connection from the environment.

```ts title="agent/agent.ts"
import { defineAgent } from "eve";

export default defineAgent({
  model: "anthropic/claude-opus-5.5",
  experimental: {
    workflow: { world: "better-supabase/workflow-sdk/world" },
  },
});
```

```bash title=".env"
SUPABASE_DB_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
```

eve 0.71 runs Workflow spec version 8, the version the World speaks, and
rejects a World with another one. The
[Workflow SDK page](/docs/blocks/workflow-sdk) lists the other variables.

## Sign-in on eve routes [#sign-in-on-eve-routes]

`supabaseAuth` is an eve route `AuthFn`. It reads the Supabase session from
the cookie or the bearer token and verifies it locally against the
project's keys, so it never calls the Auth server. A request without a
session falls through to the next entry, and an invalid token gets a 401.

```ts title="agent/channels/eve.ts"
import { eveChannel } from "eve/channels/eve";
import { localDev } from "eve/channels/auth";
import { loadEnv } from "better-supabase/env";
import { supabaseAuth } from "better-supabase/eve";

export default eveChannel({
  auth: [supabaseAuth({ env: loadEnv() }), localDev()],
});
```

The principal is `{ principalType: "user", principalId: <user id> }`, and
its attributes carry `tenantId` (the `tenant_id` claim, or the claim in
`tenantClaim`), the user's `roles` in that tenant and `isAnonymous`. The
roles come from the `memberships` claim, or the claim in `membershipsClaim`
when your authorization hook writes memberships elsewhere.
`attributes(claims)` adds your own. `principalOf(auth)` builds the same
principal from an `AuthState` you already have.

## Connections [#connections]

`credentialAuth` gives an MCP connection its token from a
`CredentialProvider`. With `owner: "app"` every session shares the app's
credential; with `owner: "user"` each signed-in user gets their own, and a
session without a user is asked to sign in.

```ts title="agent/connections/linear.ts"
import { defineMcpClientConnection } from "eve/connections";
import { vaultCredentials } from "better-supabase/credentials";
import { credentialAuth } from "better-supabase/eve";

import { transport } from "../lib/blocks";

export default defineMcpClientConnection({
  url: "https://mcp.linear.app/mcp",
  description: "The team's Linear workspace",
  auth: credentialAuth({
    provider: vaultCredentials({ transport }),
    ref: { provider: "vault", secret: "linear", scope: "user" },
    owner: "user",
    connection: "Linear",
  }),
});
```

A user without a stored credential gets eve's authorization flow when the
provider can start one (Vercel Connect can, Vault can't): eve sends the
user to the provider and resumes the turn after the callback. Pass
`interactive: false` to report `authorization.required` instead. Provider
errors map to eve's errors: a missing or unauthorized credential becomes
`ConnectionAuthorizationRequiredError`, a forbidden or invalid one
`ConnectionAuthorizationFailedError`, and anything else a `DbException`.

## Memory [#memory]

`supabaseMemory` is a memory provider over the memory block, with
optional tenant documents from the knowledge block. Pass both blocks on a
service transport, since eve calls the provider outside the user's request.

```ts title="agent/memory/profile.ts"
import { defineMemory, defineMemoryProvider } from "eve/memory";
import { byPrincipal } from "eve/memory/scope";
import { supabaseMemory } from "better-supabase/eve";

import { knowledge, memory } from "../lib/blocks";

export default defineMemory({
  description: "What the agent knows about this user and their team",
  scope: byPrincipal,
  provider: defineMemoryProvider(supabaseMemory({ memory, knowledge })),
});
```

On `turn.started` the provider returns the user's core memory, the
archival memories and knowledge chunks closest to the new message (`k`,
5 by default) and stores that answer under eve's operation id, so a
replayed step recalls the same messages. Every read and write is
partitioned by the memory scope key and the signed-in user; sessions
without a user and tenant recall nothing. Knowledge search defaults to
the tenant's shared documents, because the provider searches as the
service role; widen `knowledgeScopes` only when every member may read the
wider scope.

The model gets `remember` and `forget` tools (eve shows them as
`<slot>__remember`). Pass `extract(messages, ctx)`, an LLM call of yours, to
also keep facts from every finished turn; `tools: false` drops the tools.

To keep eve's own file memory instead, store it in Postgres with
`supabaseDocumentBackend`. A write with a stale version throws
`MemoryDocumentConflictError`, and eve retries with the current document.

```ts title="agent/memory/notes.ts"
import { defineMemory } from "eve/memory";
import { fileMemory } from "eve/memory/file";
import { byPrincipal } from "eve/memory/scope";
import { supabaseDocumentBackend } from "better-supabase/eve";

import { memory } from "../lib/blocks";

export default defineMemory({
  description: "Notes the agent keeps for itself",
  scope: byPrincipal,
  provider: fileMemory({ backend: supabaseDocumentBackend({ memory }) }),
});
```

## Sessions in the chat tables [#sessions-in-the-chat-tables]

`persistSessions` returns hook handlers that copy eve sessions into the
canonical chat tables, so the chat UI, search, memory recall and analytics
read them like any other chat.

```ts title="agent/hooks/persist.ts"
import { defineHook } from "eve/hooks";
import { persistSessions } from "better-supabase/eve";

import { chats } from "../lib/blocks";

export default defineHook({
  events: persistSessions({ chats, agentId: "support" }),
});
```

| eve event                                         | Written                                                                  |
| ------------------------------------------------- | ------------------------------------------------------------------------ |
| `session.started`                                 | an `ai_chats` row with the id `chatIdOf(sessionId)`, owned by the user   |
| `turn.started`                                    | an `ai_runs` row with the engine `eve`                                   |
| `message.received`                                | the user message, in the canonical format                                |
| `message.completed`                               | the assistant message, format `eve`, with eve's event as the native copy |
| `turn.completed`, `turn.failed`, `turn.cancelled` | the run's status: `completed`, `failed` or `cancelled`                   |

Each write is keyed by the event, so a redelivered event stores nothing
new. The chat id is a UUID derived from the session id, so a client that
knows the chat can open the eve session with
`useEveAgent({ initialSession: { sessionId, streamIndex: 0 } })`. Sessions
without a signed-in user and tenant are skipped.

## The inbox as a channel [#the-inbox-as-a-channel]

`routeInbox` connects the [inbox](/docs/blocks/inbox) to eve's Chat SDK
channel. Build the channel over [`inboxAdapter`](/docs/chat-sdk/inbox-adapter)
and the Postgres state, then hand it to `routeInbox`:

```ts title="agent/channels/inbox.ts"
import { chatSdkChannel } from "eve/channels/chat-sdk";
import { createSupabaseState, inboxAdapter } from "better-supabase/chat-sdk";
import { routeInbox } from "better-supabase/eve";

import { inbox, transport } from "../lib/blocks";

const bridge = chatSdkChannel({
  userName: "Acme agent",
  adapters: {
    inbox: inboxAdapter({
      inbox,
      userName: "Acme agent",
      verify: (request) =>
        request.headers.get("authorization") ===
        `Bearer ${process.env.INBOX_BOT_SECRET}`,
    }),
  },
  state: createSupabaseState({ transport }),
});

routeInbox(bridge, { inbox });

export default bridge.channel;
```

A contact's message in a conversation in bot mode queues an `inbox_bot`
job. Drain the queue with a route that posts each job to the adapter's
webhook, which eve mounts at `/eve/v1/inbox`:

```ts title="app/api/jobs/drain/route.ts"
import "server-only";

import { jobs } from "@/lib/jobs";

export const GET = jobs.drainRoute({
  secret: process.env.CRON_SECRET,
  handlers: {
    inbox_bot: async (payload) => {
      const response = await fetch(`${process.env.APP_URL}/eve/v1/inbox`, {
        method: "POST",
        headers: {
          authorization: `Bearer ${process.env.INBOX_BOT_SECRET}`,
          "content-type": "application/json",
        },
        body: JSON.stringify(payload),
      });
      if (!response.ok) throw new Error(`eve answered ${response.status}`);
    },
  },
});
```

The agent's replies post back into the conversation as the bot. A message
reaches eve only while the conversation's `bot_mode` is `bot`, so a staff
handoff pauses the agent. The turn's principal is the contact
(`principalType: "contact"`, with `tenantId` and `conversationId`), never a
user, so `owner: "user"` connections and per-user memory stay closed to
contacts.

## Limitations [#limitations]

* A `turn.started` that eve redelivers after its `turn.completed` opens a
  second run row. Redeliveries before the turn ends are idempotent.
* The adapter keeps no eve session id or stream index of its own: the chat
  id comes from the session id, and a resumed session reads from stream
  index 0.
* Recall and capture records sit in `memory_documents` under the scope
  `eve-ops` and expire after 7 days; `memory.documents.purge()` deletes
  them.

# Examples

> Runnable apps for every integration, built against a local stack.

Source: https://bettersupabase.com/docs/examples

Each example in [`apps/examples`](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples)
was set up with `better-supabase init`, generates its types from the same
schema, and runs against the local stack from `supabase/config.toml`.

| Example          | Shows                                                                                                                                                                                           |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `nextjs`         | A multi-tenant SaaS app on Cache Components, with every page proved instant by `instant()` tests                                                                                                |
| `hono-api`       | `createHono`, `bs.resource` with a list query, a custom route, and an admin route behind `bs.require`                                                                                           |
| `express`        | Express 5 on `better-supabase/node`: `toExpress`, a role `guard`, `problemErrorHandler`, and a smoke test against the local stack                                                               |
| `orpc-api`       | An oRPC router with valibot inputs, `bs.authed` with roles, and a contract-first OpenAPI server on Hono                                                                                         |
| `expo-powersync` | An Expo Router app: web reads in server loaders; iOS and Android read PowerSync with `useWatch`, upload with `createUploadConnector`, sign in with a magic link or OAuth, and register for push |
| `edge`           | An Edge Function with `bs.routes`, typed route params and role guards                                                                                                                           |
| `mcp`            | An MCP server as an Edge Function, exposing tables as tools, with `requiredRoles`                                                                                                               |
| `eve`            | An [eve](/docs/eve) agent on Next.js with the Supabase World, sign-in, Vault connections, memory and the inbox channel                                                                          |
| `vite-react`     | An SPA with `createClient`, `useSignIn`, `useLiveQuery`, debounced search and an optimistic create                                                                                              |

## Run them [#run-them]

```bash
supabase start
pnpm build:packages
pnpm --filter @better-supabase/example-nextjs dev
```

The examples use the stack from `supabase/config.toml` (API on port 55421).
To use another stack, set `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY` and
`SUPABASE_SECRET_KEY`.

## The Next.js SaaS app [#the-nextjs-saas-app]

`apps/examples/nextjs` is a generic SaaS app on Next.js Cache Components,
shadcn/ui (Base UI), next-intl and the local stack. Every page runs on the
real Auth server and database; nothing is mocked. Sign in with one of the
seeded users from `supabase/seed.sql` (password `password123`):

| User                | Role                            |
| ------------------- | ------------------------------- |
| `admin@acme.test`   | Owner of Acme, member of Globex |
| `member@acme.test`  | Member of Acme                  |
| `owner@globex.test` | Owner of Globex                 |

The features and the better-supabase parts they use:

| Feature                               | Uses                                                                                                                                                                                                         |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Sign-in, sign-up, password reset, MFA | `bs.proxy` with `protect`, the browser client, the Auth server and Mailpit                                                                                                                                   |
| Organizations, switcher, invitations  | the `organizations` and `invitations` SQL modules with `better-supabase/blocks/organizations`                                                                                                                |
| Customers list and detail             | `defineListQuery` with facets, `bs.cached()` in `"use cache: private"`, comments, logo upload                                                                                                                |
| Notifications                         | the `notifications` module with `better-supabase/blocks/notifications`, a live unread badge                                                                                                                  |
| Workflows                             | `better-supabase/blocks/workflows` and the Supabase World: a live runs list, run detail with cancel, an approval with `authorizeHook`, schedules                                                             |
| Workflow builder                      | `better-supabase/blocks/workflow-builder` on `@xyflow/react`: a node palette from the step library, a live run overlay, approvals on the canvas, trigger and credential drawers, publish with a version diff |
| Members, roles and permissions        | the `access` module in custom mode, `can()` in policies and the UI                                                                                                                                           |
| Billing and plan features             | the `entitlements` and `usage` modules                                                                                                                                                                       |
| API keys and audit log                | the `api-keys` and `audit` modules, with a CSV export                                                                                                                                                        |
| Profile, settings, onboarding         | the `profiles`, `settings` and `onboarding` modules                                                                                                                                                          |
| Announcements and a beta page         | the `announcements` and `flags` modules                                                                                                                                                                      |

The routes are localized under `/en` and `/nl` with `next/root-params`, and
the translations are `.po` files read directly by next-intl. The theme
(light, dark or system) is applied before the first paint.

`pnpm --filter @better-supabase/example-nextjs test:e2e` resets the stack,
builds the app with the testing API and runs the Playwright specs in
`apps/examples/nextjs/e2e`. They click every menu page for each user in both
languages under `instant()`, and walk through sign-in, the organization
switch, the language and theme switches and an invitation.

## Real-world ports [#real-world-ports]

`tests/validation-crm` and `tests/validation-request-context` rewrite code
from two production apps on better-supabase:

* **CRM customers service**: the customer list, which has status,
  tag, assignee and nested contact search filters plus four includes, becomes
  one `paginate` call that sends a single request. It is generated from and
  typechecked against a 263-table schema.
* **CRM latency ports**, each asserting its request budget with
  `db.$stats()`:
  * The customers overview is a `defineListQuery` with facet counts and
    per-row aggregates: 2 calls in 1 wave.
  * The app-chrome badges (unread notifications, open tasks, pending
    approvals) are one read set: 1 call.
  * The customer portal is scoped by `tenant({ claim: 'customer_id' })` on
    claims validated by `betterSupabase.claims()`: 2 calls in 1 wave.
* **An app Supabase package**: env validation, request headers, a
  user-auth guard, a cron guard with a named secret key, avatar storage and
  the browser client. Its request-context tests run against the local stack.

# Add another SDK

> The converter, stream and engine contracts an adapter for TanStack AI, LangChain, the OpenAI SDK or Temporal implements to use the AI and workflow blocks.

Source: https://bettersupabase.com/docs/extending/add-an-sdk

The AI and workflow blocks don't import an SDK. Chats store a canonical
message, streams store strings, and the run registry stores what any
engine reports. The AI SDK, Chat SDK and Workflow SDK adapters are one
implementation of these contracts; an adapter for another SDK implements
the same ones in its own subpath or package.

## The rules [#the-rules]

These come from [ADR 0010](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0010-neutral-blocks-and-sdk-adapters.md):

* An adapter owns no tables and no SQL. It calls the blocks.
* An engine that needs its own storage gets a SQL module named after the
  engine, such as `workflow-sdk-world` or `chat-sdk-state`.
* The SDK is an optional peer: load it lazily or type it structurally, so
  apps that don't use the adapter never install it.
* Tokens live behind a `credential_ref` and a
  [credential provider](/docs/extending/credential-providers), never in an
  adapter's options or rows.

## Chat: the message converter [#chat-the-message-converter]

The [AI chat block](/docs/blocks/ai-chat) stores every message as an
`AiMessage`: an `id`, a `role` (`system`, `user`, `assistant` or `tool`),
`parts` and optional `metadata`. A part is `text`, `reasoning`, `file`,
`tool-call`, `tool-result`, `tool-approval`, `source`, `data` or `step`.
The JSON Schema is `schemas/ai-message-v1.json`, and `aiMessageSchema` is
the same check as a Standard Schema.

An adapter writes two functions, one in each direction:

| SDK              | From the SDK                                               | Back to the SDK                      |
| ---------------- | ---------------------------------------------------------- | ------------------------------------ |
| AI SDK (shipped) | `fromUIMessage(message)`                                   | `toUIMessage(message)`               |
| TanStack AI      | its UI message, parts and tool calls                       | the same shape for `useChat`         |
| LangChain        | `BaseMessage` (`HumanMessage`, `AIMessage`, `ToolMessage`) | the `BaseMessage` subclasses         |
| OpenAI SDK       | Responses API input and output items                       | the input items for the next request |

Keep anything the canonical form can't hold in `native`. `saveAssistant`
stores the SDK's own message next to the canonical one, with a `format`
name; `path(chatId, { native: true })` returns it, and the adapter uses it
when the format matches and converts the canonical message when it doesn't:

```ts
const appended = await chats.messages
  .appendUser(chatId, fromSdkMessage(userMessage))
  .orThrow();
const history = await chats.messages
  .path(chatId, { leafId: appended.messageId, native: true })
  .orThrow();
const input = history.map((stored) =>
  stored.format === FORMAT ? stored.native : toSdkMessage(stored),
);

// ...run the model, then:
await chats.messages
  .saveAssistant(chatId, fromSdkMessage(answer), {
    parentId: appended.messageId,
    format: FORMAT,
    native: answer,
    runId,
  })
  .orThrow();
```

Wrap the model call in `runs.claim` and `runs.release`, so one answer
streams per chat, stop requests reach it and usage is recorded. Test both
converters with every message in your SDK's fixtures: each converted message
must pass the JSON Schema, and a round trip must keep the parts.

## Streams: the stream store [#streams-the-stream-store]

Resumable output goes through a `StreamStore` from
[streams](/docs/blocks/streams): `open(id)`, `append(id, fromIdx, chunks)`,
`read(id, fromIdx)`, `status`, `isCancelled` and `close`. Chunks are
strings, so the adapter serializes the SDK's stream parts and parses them
on the way out. Writing at an index that is already stored is a no-op, so a
retried batch is harmless. A new backend (another database, a message
broker) implements the interface and passes `testStreamStore` from
`better-supabase/testing`.

## Workflows: the engine contract [#workflows-the-engine-contract]

The [workflows block](/docs/blocks/workflows) keeps a run registry that any
engine fills, so `/workflows` lists Workflow SDK and Temporal runs side by
side:

| Contract           | An adapter provides                                                                                                |
| ------------------ | ------------------------------------------------------------------------------------------------------------------ |
| `runs.record(run)` | a call on each status change: `engine`, `externalId`, `definition`, `status`, `tenant`, `actor`, `error` and times |
| `WorkflowStarter`  | `(call) => Promise<runId>` for schedules, admission and semaphores; pass `idempotencyKey` to the engine            |
| cancellation       | honor `runs.requestCancel` by cancelling the engine's run, then record `cancelled`                                 |

Statuses are `queued`, `running`, `waiting`, `completed`, `failed` and
`cancelled`. A Temporal adapter maps `WorkflowExecutionStatus` onto them,
uses `idempotencyKey` as the workflow id, and records from an interceptor
or a completion activity.

The [workflow builder](/docs/blocks/workflow-builder) adds two more:

| Contract         | An adapter provides                                                                                    |
| ---------------- | ------------------------------------------------------------------------------------------------------ |
| `GraphCompiler`  | `(graph) => compiled`, stored through the service transport; a starter compiles again, never trusts it |
| `BuilderStarter` | `(call) => Promise<runId>` that starts a published version with its input                              |
| `GraphRuntime`   | `runKey`, `step(call)`, `sleep(ms)` and `approval(token, node)` for `executeGraph` inside the engine   |

`executeGraph(graph, input, runtime)` walks the graph; the adapter's
runtime maps `step` to a durable step (a Temporal activity), `sleep` to a
durable timer and `approval` to a signal named by the token.
`better-supabase/workflow-sdk/builder` is the reference implementation.

## Where to put it [#where-to-put-it]

An adapter inside this repository is a subpath such as
`better-supabase/langchain`, with its own docs page and the SDK as an
optional peer. An adapter outside it is a package that depends on
`better-supabase` and uses only its public exports. In both cases, run the
conformance kits from `better-supabase/testing` for every interface it
implements.

# Authorization providers

> Let another authorization system answer permission checks in the SQL modules, bucket and topic policies, API keys and doctor, through one versioned config key.

Source: https://bettersupabase.com/docs/extending/authorization-providers

An authorization provider is a plain object in the `authorization` key of
`better-supabase.config.ts`. It tells better-supabase which SQL functions
answer "does this user hold this permission", which scopes exist, which
membership tables the provider reads, and which claims its access token hook
writes. better-supabase never imports the provider: the object is data, and
the provider's own package builds it, usually from the files that package
generates.

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";
import { authorizationProvider } from "your-authorization-package";

export default defineConfig({
  authorization: authorizationProvider(),
  sql: { modules: { access: { model: "provider" }, organizations: {} } },
});
```

[PermDock](https://permdock.com/docs/adapters/better-supabase) builds one
from its manifest and catalog.

The provider covers the database side. Permission checks in your server
code (a resource's `permissions`, an action's or MCP tool's `permission`, a
route guard) go to a runtime [authorizer](/docs/extending/authorizers)
instead, passed as `authorizer` to the server. An authorization package can
ship both: the provider for the config, and an authorizer for the server
that answers with the same permission keys.

## What reads it [#what-reads-it]

| Feature                                                                                    | What it takes from the provider                                                   |
| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
| `sql.modules.access.model: "provider"`                                                     | `can()` and the modules' checks call `functions`, at `tenantScope`                |
| `entitlements.memberships: "provider"`                                                     | `has_entitlement` and `entitlement_members()` call `memberIds` and `memberIdsFor` |
| Bucket and topic `access` policies with `sql`                                              | the `idsWith` and `isPlatform` templates, for any scope in `scopes`               |
| [API keys](/docs/blocks/api-keys) with `claim`                                             | nothing at runtime; the claim names follow the provider's functions               |
| `better-supabase sql add tenant`                                                           | refuses when `tokenHook.ownedClaims` has `memberships` (`--force` writes it)      |
| `better-supabase gen`                                                                      | refuses a bucket or topic key that isn't `sqlComplete: true`                      |
| [Doctor](/docs/cli/doctor) BS107, BS213, BS214, BS324, BS404, BS405, BS407 to BS409, BS411 | `requires`, `decidingColumns`, `permissions`, `memberships`, `tokenHook`          |

`entitlements.memberships` defaults to `"provider"` when `authorization` is
set. The access model stays what `sql.modules.access.model` says.

## The contract [#the-contract]

```ts
interface AuthorizationProvider {
  apiVersion: 1;
  name: string;
  scopes: { name: string; idType?: string; parent?: string }[];
  tenantScope: string;
  functions: AuthorizationFunctions;
  requires?: { function: string; args?: string; role: string }[];
  permissions?: { key: string; sqlComplete?: boolean; scopes?: string[] }[];
  memberships?: {
    table: string;
    userColumn: string;
    scope: { column: string } | { value: string };
    idColumn: string;
  }[];
  suspension?: { user?: DisabledRow; tenant?: DisabledRow };
  roleSources?: AuthorizationRoleSource[];
  decidingColumns?: string[];
  tokenHook?: AuthorizationTokenHook;
  approvals?: { distinctApprover?: boolean };
  problems?: string[];
}
```

`AuthorizationProvider` and its parts are exported from
`better-supabase/config`. A scope name is lower case (`[a-z][a-z0-9_]*`),
because it goes into templates unquoted. `problems` lists what the provider
found wrong while building the object; doctor reports each one.

The CLI validates the object when it loads the config. Every scope name in
`tenantScope` and `parent` must be declared, parents must not form a cycle,
the tenant scope needs an `idType`, and `idType` is `uuid`, `text`, `bigint`
or `integer`. Each `decidingColumns` entry is `schema.table.column`, and
`tokenHook.budget.bytes` is a positive integer. An object with another
`apiVersion` gets one message naming both versions instead of a list of field
errors.

### Functions [#functions]

Each function is a SQL template. better-supabase fills the placeholders and
puts the result into a policy or a module function:

| Template         | Placeholders                              | Returns                                                    |
| ---------------- | ----------------------------------------- | ---------------------------------------------------------- |
| `idsWith`        | `{permission}`, `{scope}`                 | a set of the `{scope}` ids where the caller holds the key  |
| `isPlatform`     | `{permission}`                            | `boolean`: the caller holds the key platform-wide          |
| `idsWithFor`     | `{user}`, `{permission}`, `{scope}`       | as `idsWith`, for another user                             |
| `isPlatformFor`  | `{user}`, `{permission}`                  | as `isPlatform`, for another user                          |
| `memberIds`      | `{scope}`                                 | the `{scope}` ids the caller is a member of                |
| `memberIdsFor`   | `{user}`, `{scope}`                       | as `memberIds`, for another user                           |
| `canAssign`      | `{role}`, `{tenant}`, `{scope}`           | `boolean`: the caller may give `{role}` in `{tenant}`      |
| `canAssignFor`   | `{user}`, `{role}`, `{tenant}`, `{scope}` | as `canAssign`, for another user                           |
| `permissionsFor` | `{user}`, `{tenant}`, `{scope}`           | `text[]`: the permission keys `{user}` holds in `{tenant}` |
| `canApprove`     | `{tool}`, `{tenant}`, `{scope}`           | `boolean`: the caller may decide the tool call `{tool}`    |

`idsWith` and `isPlatform` are required. A template that leaves out a `For`
variant makes the module function answer for the caller only: called for
another user, it raises SQLSTATE `0A000`. Without `canAssign`, only the
service role assigns roles: the `access` module's `can_assign` answers false
for every member, owners included.

Some modules call the optional templates under the `provider` model:
`invitations` calls `idsWithFor`, `isPlatformFor` and `canAssignFor` to check
the inviter again when an invitation is accepted; `inbox`, `comments`, `sso`,
`notifications`, `connectors` and `workflow-sdk-world` call `idsWithFor`; and
`usage` reads memberships through `memberIds`. Doctor (BS411) warns for each
installed module whose template the provider doesn't set.

`permissionsFor` fills `better_supabase.member_permissions` and
`permission_claims`, so the access token hook can carry permission keys. The
claims cover the tenants `memberIdsFor` returns; without `memberIdsFor` they
stay empty. Without `permissionsFor`, both functions return nothing, as
before.

```ts
functions: {
  idsWith: "authz.{scope}_ids_with({permission})",
  isPlatform: "authz.is_platform({permission})",
  memberIds: "authz.member_{scope}_ids()",
  canAssign: "authz.can_assign({role}, {tenant}::text)",
},
requires: [
  { function: "authz.organization_ids_with", args: "text", role: "authenticated" },
  { function: "authz.is_platform", args: "text", role: "authenticated" },
  { function: "authz.member_organization_ids", role: "authenticated" },
  { function: "authz.can_assign", args: "text, text", role: "authenticated" },
],
```

`requires` lists every function the templates call, with `{scope}` filled,
and the role that must be able to execute it. Doctor (BS411) checks the
database has each one, and the conformance block checks the list is complete.
`idType` is the Postgres type of a scope's ids: `uuid`, `text`, `bigint` or
`integer`.

### Permissions [#permissions]

`permissions` lists the keys the provider knows. `sqlComplete: true` says its
SQL functions answer the key completely: a grant has no row condition beyond
the scope. Bucket and topic policies and the SQL modules check by role and
scope only, so `gen`, `sql add` and doctor (BS214, BS411) refuse a key that
isn't marked. Leave row-conditioned keys to the provider's table policies.

### Token hook [#token-hook]

`tokenHook` describes the custom access token hook the provider generates:

| Field              | Used for                                                                                  |
| ------------------ | ----------------------------------------------------------------------------------------- |
| `function`         | BS404 checks its grants, BS405 its shape                                                  |
| `tenantClaim`      | BS409 compares it with `claims.tenant`                                                    |
| `ownedClaims`      | BS407 reports another hook that writes them; `sql add tenant` stops on `memberships`      |
| `registeredClaims` | BS407 accepts these claims from the functions named, such as `features`                   |
| `budget`           | BS405 measures the claims it lists against `bytes` for `doctor --as`                      |
| `markers`          | comment lines in the provider's files, so doctor recognises its hook and grants migration |
| `grantsCommand`    | the command BS404 suggests                                                                |

### Tool approvals [#tool-approvals]

`canApprove` decides who may approve or deny a waiting AI tool call in the
[ai-chat block](/docs/blocks/ai-chat). Under the `provider` model,
`decide_ai_tool_approval` lets the service role and any caller `canApprove`
allows decide the call; `{tenant}` is the chat's tenant and `{tool}` the
tool's name. `approvals.distinctApprover: true` keeps the chat's owner, whose
agent asked for the call, from deciding it, for four-eyes approval. Without
`canApprove`, the chat's owner decides.

```ts
functions: {
  // ...
  canApprove: "authz.can_approve_tool({tool}, {tenant}::text)",
},
approvals: { distinctApprover: true },
```

## Bucket and topic policies [#bucket-and-topic-policies]

An `access` policy calls the access contract (`tenant_ids_with`,
`is_platform`) by default. Give it the provider's templates in `sql`, and it
calls those instead, for any scope they take. `sql: "provider"` uses the
provider's `idsWith` and `isPlatform`: `better-supabase gen` writes them into
the generated bucket meta, and `better-supabase sql sync` passes them to each
topic's `sql()`. A copy of the templates also works, so the bucket module
doesn't import the config; doctor (BS214) warns when the copy differs from the
provider.

```ts title="src/storage/documents.ts"
import { defineBucket } from "better-supabase/storage";

export const documents = defineBucket({
  id: "documents",
  path: "{organizationId}/{documentId}/{name}",
  policy: {
    access: { read: "documents.browse", write: "documents.write" },
    scope: "organization",
    sql: "provider",
  },
});
```

A topic with `sql: "provider"` throws when its `sql()` runs without
templates. Pass them yourself outside `sql sync`:

```ts
topic.sql({ functions: config.authorization.functions });
```

The scope id is compared as text, so a path or topic segment must be the id's
lowercase form. `segment` picks another path segment than `{organizationId}`.

## Test a provider [#test-a-provider]

`testAuthorizationProvider` from `better-supabase/testing` checks API v1,
plain JSON, valid scopes (known names, no parent cycle, a tenant scope with an
`idType`), the placeholders of each template (each uses its `{permission}`,
`{user}`, `{tenant}` and `{role}`, and `idsWith` and `idsWithFor` use
`{scope}` when there is more than one scope), that templates are safe to
inline (no function call without a schema, no `$$`, `;` or `--`), that
`requires` lists every function the templates call, the permission list, and
the shapes of
`memberships`, `suspension`, `roleSources`, `decidingColumns` and
`tokenHook`: `ownedClaims` is not empty, a `registeredClaims` entry is not also
owned, `budget.claims` are owned or registered, and `budget.bytes` is a
positive integer. See [conformance blocks](/docs/extending/conformance).

```ts title="tests/provider.test.ts"
import { testAuthorizationProvider } from "better-supabase/testing";
import { it } from "vitest";

it("conforms", () => testAuthorizationProvider(authorizationProvider()));
```

A provider for a new contract version sets another `apiVersion`; this release
refuses anything but `1` with a message naming both versions.

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

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

An `Authorizer` decides whether the caller holds a permission. Resources,
actions, route guards and MCP tools declare a `permission`, and
better-supabase builds an access request from the verified request context
and asks the authorizer. Anything but a clear grant refuses the request.
The interface is provider-neutral and versioned (`apiVersion: 1`):
authorization libraries ship an adapter that implements it, and
better-supabase names none of them. The schema side of authorization (the
SQL a library installs) is the separate
[`AuthorizationProvider`](/docs/extending/authorization-providers).

## The contract [#the-contract]

```ts
interface Authorizer<Ref = string> {
  apiVersion: 1;
  name: string;
  // The stable key of a permission reference, for documents, SQL and messages.
  key(ref: Ref): string;
  // The default permission of a table operation; makes `permissions: true` work.
  forOperation?(table: string, operation: string): Ref | undefined;
  evaluate(
    request: AuthorizationRequest<Ref>,
  ): AuthorizationOutcome | Promise<AuthorizationOutcome>;
  // The batch form; results keep the request order.
  evaluations?(
    requests: readonly AuthorizationRequest<Ref>[],
  ): Promise<readonly AuthorizationOutcome[]>;
}
```

`Ref` is whatever your permissions are: plain strings, or typed objects your
library defines. The `permission` options of resources, actions and tools
are typed from the authorizer you pass, so a typo fails to compile when
`Ref` is narrower than `string`. `key()` turns a reference into the string
the API document (`x-better-supabase-permission`), error responses and logs
use.

`defineAuthorizer` from `better-supabase/server` builds one. It sets
`apiVersion: 1`, infers `Ref` from `key()`, and types the request
`evaluate` receives. A minimal authorizer over a table of grants:

```ts title="src/lib/authorizer.ts"
import { defineAuthorizer } from "better-supabase/server";

export const authorizer = defineAuthorizer({
  name: "grants",
  key: (permission: string) => permission,
  async evaluate({ subject, action, context }) {
    const granted = await hasGrant(subject.id, action, context["tenant"]);
    return granted ? { outcome: "granted" } : { outcome: "denied" };
  },
});
```

Pass a type argument for typed references:
`defineAuthorizer<Permission>({ ... })`. The types come from the same
subpath:

| Type                    | What it is                                                                   |
| ----------------------- | ---------------------------------------------------------------------------- |
| `Authorizer<Ref>`       | The contract above                                                           |
| `AnyAuthorizer`         | An authorizer of any `Ref`, for code that stores or forwards one             |
| `AuthorizationRequest`  | The request `evaluate` receives (see below)                                  |
| `AuthorizationOutcome`  | What `evaluate` returns                                                      |
| `AuthorizationSubject`  | The request's `subject`                                                      |
| `AuthorizationResource` | The request's `resource`                                                     |
| `PermissionRef<A>`      | The reference type of authorizer `A`, `string` when `A` is not an authorizer |

## The request [#the-request]

Each request follows the [AuthZEN](/docs/standards/authzen) Authorization
API 1.0 information model:
a subject, an action, a resource and a context.

| Field      | What it holds                                                                                                          |
| ---------- | ---------------------------------------------------------------------------------------------------------------------- |
| `subject`  | The caller, from the verified context only (see below)                                                                 |
| `action`   | The permission reference                                                                                               |
| `resource` | What is acted on: `type`, an optional `id`, and `properties`                                                           |
| `context`  | `tenant` (when the request has one), `aal` (from the token), and `scopes` (the token's `scope` claim, split on spaces) |

The subject's `type` is the actor kind (`user` or `service`), or `agent` for
a user token that carries an `act` claim (a delegated token). Its `id` is the
actor id, and its `properties` hold `role`, `email` and `impersonator` when
they are set. A caller without a session is `{ type: "anon", id: "anonymous" }`.
Nothing the client sends ends up in the subject.

| Where the permission is declared | `resource`                                                                    |
| -------------------------------- | ----------------------------------------------------------------------------- |
| A resource operation             | `type` is the table; `id` and the row as `properties` (the body for `create`) |
| A resource action                | the table, plus the key and the row for an item action                        |
| An MCP tool                      | `{ type: "mcp_tool", id: <tool name>, properties: <arguments> }`              |
| A route guard                    | `{ type: "route" }`                                                           |
| A server or block action         | `{ type: "action", properties: <the validated input> }`                       |

## Outcomes [#outcomes]

`evaluate` returns one of three outcomes, each with an optional `context`:

| Outcome                                       | Answer                                                        |
| --------------------------------------------- | ------------------------------------------------------------- |
| `{ outcome: "granted" }`                      | The request goes on                                           |
| `{ outcome: "denied", reason? }`              | 403 with `code: "PERMISSION_DENIED"`; `reason` is the message |
| `{ outcome: "approval-required", approval? }` | 403 with `code: "APPROVAL_REQUIRED"` and `approval: { id }`   |

Both refusals are `forbidden` errors that carry the permission key in
`permission`, rendered as [Problem Details](/docs/auth/problems).

## Fail closed [#fail-closed]

Every way an answer can go wrong denies:

| Case                                               | Error message                                        |
| -------------------------------------------------- | ---------------------------------------------------- |
| A permission is declared but no authorizer is set  | `A permission is declared, but no authorizer is set` |
| `key()` throws                                     | `Unknown permission`                                 |
| `evaluate` throws or rejects                       | `The authorizer failed for <key>`                    |
| `evaluate` returns anything but the three outcomes | `The authorizer gave no decision for <key>`          |

All of them are `PERMISSION_DENIED`. A permission check never throws: the
operation returns the error as a `Result`, like every other database error.
When a resource lists rows, each row is checked (through `evaluations` when
the authorizer has it), and any failure leaves the row out.

## Set the authorizer [#set-the-authorizer]

Pass it once, where the server is created. Every resource, action, route
guard and MCP tool of that server uses it:

```ts title="src/server.ts"
import { createHono } from "better-supabase/hono";
import { authorizer } from "./lib/authorizer";

const bs = createHono(betterSupabase, { authorizer });
```

| Where                                                                   | Option                                     |
| ----------------------------------------------------------------------- | ------------------------------------------ |
| `createServer` (`better-supabase/server`)                               | `authorizer`                               |
| `createHono`, `createEdge`, `createNext`, `createMcp`                   | `authorizer`                               |
| The standalone route guards (Express, Fastify, Koa, h3, Elysia, NestJS) | `authorizer` on the guard                  |
| `defineApi`                                                             | `authorizer`, for the keys in the document |

Give `defineApi` the same authorizer so the document shows each operation's
permission key and its 403 response (see
[Permissions in the document](/docs/specs/customize#permissions-in-the-document)).

## Test an authorizer [#test-an-authorizer]

`testAuthorizer` from `better-supabase/testing` runs the conformance kit
against your adapter. It checks that the authorizer targets API v1 and has a
name, that `key()` returns a stable, non-empty string, that every answer is
one of the three outcomes and leaves the request unchanged, that
`evaluations` agrees with `evaluate` and keeps the order, that the decision
comes from the subject and never from a forged input, and that an unknown
permission is never granted:

```ts title="tests/authorizer.test.ts"
import { test } from "vitest";
import { testAuthorizer } from "better-supabase/testing";
import { authorizer } from "../src/lib/authorizer";

test("the authorizer conforms", async () => {
  await testAuthorizer(authorizer, {
    permissions: ["customers.read", "customers.delete"],
    unknown: "nothing.here",
    granted: { permission: "customers.read", context: adminContext },
  });
});
```

It resolves with the report, and rejects with a `ConformanceError` that
lists every failed check.

For tests and examples that need an authorizer but not a library,
`staticAuthorizer` decides from the subject's role and id. See
[Testing](/docs/testing#authorizers).

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

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

A block covers the common case, and your app adds what makes it yours: a
`plan` column on organizations, a rule that refuses an invite above the seat
limit, a request id on every database call, a method the block doesn't have.
Each need has one place, from the least code to the most:

| You want to                          | Use                                                   | Where      |
| ------------------------------------ | ----------------------------------------------------- | ---------- |
| Change what the block does           | `sql.modules.<module>` options, permissions and hooks | SQL config |
| Add columns and get them back, typed | `options.extraColumns` and the `fields` option        | SQL and TS |
| Refuse, rewrite or observe a call    | `hooks` on the block, or SQL `before_`/`after_` hooks | TS or SQL  |
| Run code around every database call  | `wrapTransport(transport, middleware)`                | TS         |
| Add a method                         | `extendBlock(block, build)`                           | TS         |

Rules that must hold for every write, including writes that skip
TypeScript, belong in SQL: a permission, a check constraint or a `before_`
hook. Hooks in TypeScript are for app logic around a call: plan limits,
defaults, metrics.

## Your own fields [#your-own-fields]

Add columns to a table the block creates with `options.extraColumns`, keyed
by column name, each with its SQL type. The module adds them to the table,
copies them on create and update, and returns them from its reads. A name
the module already uses is refused, so a column can't shadow `role` or `id`.

```ts title="better-supabase.config.ts"
sql: {
  modules: {
    organizations: {
      options: {
        extraColumns: {
          plan: "text not null default 'free'",
          seats: "integer not null default 3",
        },
      },
    },
    profiles: {
      options: { extraColumns: { locale: "text not null default 'en'" } },
    },
  },
},
```

On a table you adopt, the columns already exist: list them in
`options.attributes` instead (organizations), or nothing at all (profiles,
which return the whole row).

Then pass a [Standard Schema](https://standardschema.dev) for those fields
(zod, valibot, arktype), keyed by database name. The block types its reads
and writes with it, parses the values it reads, and validates writes before
they reach the database:

```ts title="src/lib/organizations.ts"
import { createOrganizations } from "better-supabase/blocks/organizations";
import { z } from "zod";

export const OrganizationFields = z.object({
  plan: z.enum(["free", "pro", "enterprise"]),
  seats: z.number().int().positive(),
});

const organizations = createOrganizations({
  transport,
  fields: OrganizationFields,
});

const [first] = await organizations.mine().orThrow();
first?.plan; // "free" | "pro" | "enterprise"

await organizations.update(id, { seats: 0 }); // { ok: false, error: { kind: "validation" } }
```

A write sets only some fields, so issues about fields it leaves out are
ignored; the database applies its defaults and `not null` checks. A stored
value the schema rejects is a `validation` error on the read, so keep the
schema able to read the rows you already have. The parsed fields are merged
over the row, so the block's own columns stay even when the schema strips
keys it doesn't name.

Notifications type their `data` the same way, per notification type: see
[typed data](/docs/blocks/notifications#reading).

| Block         | Columns from                                    | `fields` types                |
| ------------- | ----------------------------------------------- | ----------------------------- |
| organizations | `options.attributes`, `options.extraColumns`    | `mine()`, `create`, `update`  |
| profiles      | `options.extraColumns`, or any column you adopt | `mine()`, `updateMine`        |
| notifications | the `data` of each type                         | `send`, `get`, `list`, `page` |

## Hooks [#hooks]

`hooks` runs your code around a block's methods, keyed by method name. A
`before` hook gets the arguments and returns nothing to go on, new arguments
to replace them, or a `DbError` to refuse the call, which then returns that
error without reaching the database. An `after` hook gets a copy of the
result and the arguments: it can record, never change. When it throws, the
error is logged and the caller still gets the result.

```ts
import { dbError } from "better-supabase";

const organizations = createOrganizations({
  transport,
  hooks: {
    invite: {
      async before([request]) {
        if (request.organizationId === null) return;
        const used = await seatsUsed(request.organizationId);
        if (used >= (await seatLimit(request.organizationId))) {
          return dbError("forbidden", "Every seat is taken", {
            hint: "SEATS_FULL",
          });
        }
      },
      after(result, [request]) {
        if (result.ok) metrics.increment("invites", { role: request.role });
      },
    },
    create: {
      before: ([attributes, options]) => [
        { plan: "free", ...attributes },
        options,
      ],
    },
  },
});
```

Hooks are typed per method, so `request` above is an `InviteRequest` and
`result` a `Result<InvitationSent>`. Every block takes them through
[`createBlocks`](/docs/blocks/create-blocks), keyed by block name, and
siblings that call a block (ai-tasks sending notifications) go through its
hooks too:

```ts
const blocks = createBlocks(
  {
    transport,
    hooks: { notifications: { send: { before: checkQuietHours } } },
  },
  { organizations: createOrganizations, notifications: notificationsFactory },
);
```

For a block you build yourself, `withBlockHooks(block, hooks)` from
`better-supabase/blocks` does the same.

### Hooks in SQL [#hooks-in-sql]

A hook in TypeScript runs only for calls through the block. For a rule
every caller must meet, use the SQL hooks the module calls inside its
functions, named `before_<entity>_<action>` and `after_<entity>_<action>`.
A `before_` hook raises to refuse the write; an `after_` hook runs in the
same transaction, so seeding rows for a new organization commits or rolls
back with it.

| Module        | SQL hooks                                                                                                                                                                                                                     |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| organizations | `before_organization_create(attrs jsonb, owner uuid)`, `after_organization_create(organization, owner uuid)`, `before_organization_update(organization, attrs jsonb)`, `after_organization_update(organization, attrs jsonb)` |
| profiles      | `before_profile_update(attrs jsonb, user_id uuid)`, `after_profile_update(user_id uuid)`, `after_profile_sync(user_id uuid)`                                                                                                  |
| notifications | `before_notification_send(notification jsonb)`, `after_notify(event uuid)`, and `notification_audience`                                                                                                                       |

The `organization` argument has the type of the organizations table's id.
Define the function in the hooks schema (`public` by default) and the
module calls it when it exists; [SQL hooks](/docs/extending/events#sql-hooks)
shows how to point a module at another schema or function.

## Transport middleware [#transport-middleware]

Every block calls its SQL functions through a transport. `wrapTransport`
runs middleware around each call, for every block that uses the transport:
tracing, a statement timeout, an argument every function of your own takes.
Middleware has `apiVersion: 1` and a `name`, and passes the request on with
`next`. It rejects with the error `next` rejected with, so the block still
maps it to a `DbError`.

```ts
import {
  defineTransportMiddleware,
  rpcTransport,
  wrapTransport,
} from "better-supabase/blocks";

const traced = defineTransportMiddleware({
  name: "tracing",
  call: (request, next) =>
    tracer.startActiveSpan(`${request.schema}.${request.fn}`, async (span) => {
      try {
        return await next(request);
      } finally {
        span.end();
      }
    }),
});

const transport = wrapTransport(rpcTransport(supabase), traced);
```

`testBlockTransportMiddleware(middleware)` from `better-supabase/testing`
checks the contract; see [conformance](/docs/extending/conformance).

## New methods [#new-methods]

`extendBlock(block, build, options)` adds methods. `build` gets the block,
so a new method can compose the existing ones, and `call` for SQL functions
you add to the block's schema, with the block's error mapping. Pass the
block's `transport` (and `schema`) for `call`. Redefining a method the block
has throws: wrap it with a hook instead.

```ts
import { extendBlock } from "better-supabase/blocks";

const organizations = extendBlock(
  createOrganizations({ transport }),
  (base, { call }) => ({
    archive: (organizationId: string) =>
      call(
        "archive_organization",
        { organization: organizationId },
        () => true as const,
      ),
    owned: () =>
      base
        .mine()
        .map((memberships) => memberships.filter((m) => m.role === "owner")),
  }),
  { transport },
);
```

The extended block keeps every method's `Result` contract: a new method
returns an `AsyncResult` and never throws for a database error.

# Conformance blocks

> Prove a custom executor, cache adapter, sink, auth resolver, framework adapter, generator or plugin meets its contract.

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

`better-supabase/testing` has a block per extension interface. A block runs every
check and resolves with a report, or throws a `ConformanceError` that lists
all failed checks. Blocks don't depend on a test runner, so they work in vitest,
`node:test` and bun.

```ts title="kysely-executor.test.ts"
import { testExecutor } from "better-supabase/testing";
import { it } from "vitest";

it("conforms", () =>
  testExecutor(kyselyExecutor(db), {
    betterSupabase: defineSupabase(schema),
    table: "tags",
    create: { organizationId: ORG, name: "conformance" },
  }));
```

| Block                                                                   | Checks                                                                                                                                                                                                                                                                                                                                        |
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `testExecutor(executor, { betterSupabase, table?, create? })`           | rows keyed by selection aliases, limits, counts, `aborted` errors, failures returned instead of thrown, rpc failures, and with `create` a create, read and delete round trip plus a `deleteMany` past `maxAffected` that fails with `max_affected` and deletes nothing                                                                        |
| `testCacheAdapter(adapter, { table? })`                                 | table and row targets, tenants, repeated and concurrent calls, unknown tables, no mutation of the target                                                                                                                                                                                                                                      |
| `testEventSink(sink, { received? })`                                    | empty and full batches, no mutation of events, and with `received` delivery of every event                                                                                                                                                                                                                                                    |
| `testQueueBackend(backend, { queue, dedupe? })`                         | API v1, payload round trip, claimed messages hidden for their lease, attempt counts, retry then dead letter at `max_attempts`, and with `leases` stale attempts rejected and lease extension; dedupe keys return the first id; with `stats` and `listDead`, the dead letter is counted and listed, and with `retryDead` it is claimable again |
| `testSupportSessionStore(store, { admin, targets })`                    | API v1, a started session matches its input and only its admin sees it, a second start ends the first, ending works once and the ended session is listed                                                                                                                                                                                      |
| `testNotificationChannel(channel, { email?, received? })`               | API v1, a name, a frozen message sent without mutating it, a valid result, and with `received` delivery of the message                                                                                                                                                                                                                        |
| `testStreamStore(store, { id?, timeoutMs? })`                           | API v1, a name, one `open` per stream, idempotent appends that refuse a gap, reads from any index that end once the stream closes, a live read that sees later appends, a cancel the writer sees, appends refused after close, a missing stream read as empty, and `purge` with a count                                                       |
| `testChatState(state, { message, id?, ttlMs? })`                        | subscriptions, one lock holder at a time with token-checked release and extension, lock and cache TTLs, `setIfNotExists`, list trimming, and a per-thread queue that keeps the newest entries, reports its depth and drops expired ones                                                                                                       |
| `testCredentialProvider(provider, { ref, seed, userRef?, inbound? })`   | API v1, `invalid_input` for a ref of another provider, a stored token with headers, a new value seen after storing it, separate credentials per user, `revoke` then `not_found`, and with `inbound` a signed request accepted and a tampered one refused                                                                                      |
| `testWebhookSigner(signer, { verify? })`                                | API v1, a name, string headers that are stable for the same input and change with the body, and with `verify` a signature the receiver accepts                                                                                                                                                                                                |
| `testWebhookTransport(transport, { url, received? })`                   | API v1, a name, an HTTP status and string body for a POST to `url`, and with `received` delivery of the body                                                                                                                                                                                                                                  |
| `testWebhookSecretStore(store, { endpointId })`                         | API v1, secrets as strings, and with `rotate` a new secret listed first while the previous one keeps signing                                                                                                                                                                                                                                  |
| `testAuthResolver(resolver, { invalid, valid?, unrelated? })`           | `undefined` for requests without its credentials, `invalid` (never anon) for bad ones, the expected user for good ones, never throws                                                                                                                                                                                                          |
| `testAdapter(name, { betterSupabase, serve, errorsInBody?, cookies? })` | a 401 without running the handler for a refused caller, data for an allowed one, a `DbError` answered with its status, no leaked message for other errors, `bs-primary-until` after a write, pending event sends handed to `waitUntil`                                                                                                        |
| `testGenerator(generator, { meta, config?, model? })`                   | `apiVersion` 1 or unset, relative, unique paths inside the project, deterministic output, no mutation of the input                                                                                                                                                                                                                            |
| `testBlockTransportMiddleware(middleware)`                              | API v1, a name, `next` called at most once, an unchanged request, and a rejection that keeps the database error's code and hint                                                                                                                                                                                                               |
| `testAuthorizationProvider(provider)`                                   | API v1, plain JSON, valid and unique scopes with a known `tenantScope`, templates that use only their placeholders, `requires` lists every function the templates call, permissions listed once at known scopes                                                                                                                               |
| `testAuthorizer(authorizer, { permissions, granted?, unknown? })`       | API v1, a name, stable non-empty `key()` strings, a known outcome for every request without mutating it, `evaluations` that agree with `evaluate` and keep the order, decisions from the subject and never from a forged input, an unknown permission never granted, and with `granted` a grant through the same check the server runs        |
| `testPlugin(plugin, { betterSupabase, table?, context?, create? })`     | API v1, installs alone and next to the first-party plugins in either order, a known `enforce`, `repository` methods that never replace base ones, pure and deterministic `context`, `transformQuery` and `beforeMutation`, `wrapExecutor` keeps results intact, `mapError` returns a `DbError` or `undefined`                                 |

Inputs that should stay untouched are deep-frozen, so an implementation that
mutates them fails with the check that caught it.

The first-party executors, adapters, generators and plugins run these blocks in
the package's own test suite.

# Add a credential provider

> Write a CredentialProvider for Nango, Composio or a cloud secret manager, route refs to more than one provider, and check it with the conformance kit.

Source: https://bettersupabase.com/docs/extending/credential-providers

Blocks resolve every `credential_ref` through one `CredentialProvider`
(see [Credentials](/docs/extending/credentials)). Vault and Vercel Connect
ship with better-supabase; anything else is a provider you write against the
same versioned contract. The provider stays in your app or its own
package: better-supabase never depends on Nango, Composio or a cloud SDK.

## The contract [#the-contract]

| Member                                        | Required                               | Does                                                                |
| --------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------- |
| `apiVersion: 1`, `name`                       | yes                                    | the contract version and the `provider` value of the refs it serves |
| `capabilities(ref)`                           | yes                                    | `userSubjects`, `authorization`, `revoke` and `inbound` for the ref |
| `getToken(ref, { subject, scopes })`          | yes                                    | the token, its expiry and the headers that send it                  |
| `revoke(ref, { subject })`                    | yes                                    | forgets the credential; `false` when there was none                 |
| `startAuthorization`, `completeAuthorization` | when `capabilities(ref).authorization` | sends a user to connect an account and finishes the callback        |
| `verifyInbound(request, ref)`                 | when `capabilities(ref).inbound`       | checks a webhook the third party sent                               |
| `set(ref, value, { subject, description })`   | no                                     | stores or replaces a secret the app hands over, such as an API key  |

Every method returns an `AsyncResult` and never throws for a provider
error. Use these error kinds so blocks can react to them:

| Situation                                 | Kind            |
| ----------------------------------------- | --------------- |
| A ref for another provider, or malformed  | `invalid_input` |
| No credential stored for the subject      | `not_found`     |
| The user hasn't connected the account yet | `forbidden`     |
| The provider's API failed                 | `unexpected`    |

Keep the token out of errors, logs and events: only the value `getToken`
returns carries it.

## A cloud secret manager [#a-cloud-secret-manager]

A secret manager holds app credentials. This sketch reads AWS Secrets
Manager, with one secret per ref and, for per-user refs, one per user:

```ts title="src/lib/aws-credentials.ts"
import {
  DeleteSecretCommand,
  GetSecretValueCommand,
  SecretsManagerClient,
} from "@aws-sdk/client-secrets-manager";
import { AsyncResult, dbError, err, ok } from "better-supabase";
import type {
  CredentialProvider,
  CredentialRef,
  CredentialSubject,
} from "better-supabase/credentials";

const client = new SecretsManagerClient({});

function secretId(ref: CredentialRef, subject: CredentialSubject) {
  if (typeof ref.secret !== "string") return undefined;
  const perUser = ref.scope === "user";
  if (perUser && subject.type !== "user") return undefined;
  return perUser && subject.type === "user"
    ? `${ref.secret}/${subject.id}`
    : ref.secret;
}

export const awsCredentials: CredentialProvider = {
  apiVersion: 1,
  name: "aws-secrets",
  capabilities: (ref) => ({
    userSubjects: ref.scope === "user",
    authorization: false,
    revoke: true,
    inbound: false,
  }),
  getToken(ref, { subject, signal }) {
    const id =
      ref.provider === "aws-secrets" ? secretId(ref, subject) : undefined;
    if (id === undefined) {
      return AsyncResult.err(
        dbError("invalid_input", "Not an aws-secrets ref"),
      );
    }
    return AsyncResult.from(async () => {
      try {
        const out = await client.send(
          new GetSecretValueCommand({ SecretId: id }),
          { abortSignal: signal },
        );
        const token = out.SecretString ?? "";
        return ok({ token, headers: { authorization: `Bearer ${token}` } });
      } catch (cause) {
        return cause instanceof Error &&
          cause.name === "ResourceNotFoundException"
          ? err(dbError("not_found", "No secret for this ref"))
          : err(dbError("unexpected", "Secrets Manager failed"));
      }
    });
  },
  revoke(ref, { subject }) {
    const id =
      ref.provider === "aws-secrets" ? secretId(ref, subject) : undefined;
    if (id === undefined) {
      return AsyncResult.err(
        dbError("invalid_input", "Not an aws-secrets ref"),
      );
    }
    return AsyncResult.from(async () => {
      await client.send(
        new DeleteSecretCommand({
          SecretId: id,
          ForceDeleteWithoutRecovery: true,
        }),
      );
      return ok(true);
    });
  },
};
```

Google Secret Manager, Azure Key Vault and HashiCorp Vault follow the same
shape: a ref names the secret, `getToken` reads its latest version, and
`revoke` deletes it or disables the version. Cache tokens for a short time
when the API is slow; clear the cache in `revoke`.

## Nango [#nango]

[Nango](https://nango.dev) holds OAuth connections that users create, and
refreshes their tokens. A ref names the integration
(`{ "provider": "nango", "integration": "github" }`), and the Nango
connection id is derived from the subject, so each user gets their own
connection:

| Method                  | Nango call                                                                             |
| ----------------------- | -------------------------------------------------------------------------------------- |
| `getToken`              | read the connection for the integration and connection id; return its access token     |
| `startAuthorization`    | create a Connect session for the end user and return its connect link as `url`         |
| `completeAuthorization` | nothing to exchange: Nango finishes the OAuth flow; confirm that the connection exists |
| `revoke`                | delete the connection                                                                  |
| `verifyInbound`         | check the `X-Nango-Hmac-Sha256` signature of Nango's webhooks                          |

Return `forbidden` with the hint `CREDENTIAL_AUTHORIZATION_REQUIRED` when
the user has no connection yet, so callers know to start authorization.
Load `@nangohq/node` lazily or call Nango's HTTP API with `fetch`, and keep
the Nango secret key in the server's env.

## Composio [#composio]

[Composio](https://composio.dev) keeps connected accounts per user and
integration. Map the subject to Composio's user id and the ref to an auth
config (`{ "provider": "composio", "authConfig": "ac_github" }`):

| Method               | Composio call                                                      |
| -------------------- | ------------------------------------------------------------------ |
| `getToken`           | read the user's active connected account; return its access token  |
| `startAuthorization` | initiate a connection request and return its redirect URL as `url` |
| `revoke`             | delete the connected account                                       |

When you call tools through Composio itself instead of with the token,
keep the connected account id in the row's `credential_ref` and let the
provider hand the id back as the token. The contract stays the same.

## More than one provider [#more-than-one-provider]

`createServer` takes one `credentials` value. `credentialRouter` from
`better-supabase/credentials` serves several providers as one: each call goes
to the provider whose `name` matches the ref's `provider` field.

```ts title="src/lib/credentials.ts"
import {
  credentialRouter,
  vaultCredentials,
} from "better-supabase/credentials";

export const credentials = credentialRouter([
  vaultCredentials({ transport }),
  nangoCredentials,
  awsCredentials,
]);
```

A ref no provider serves fails with `invalid_input`
(`CREDENTIAL_PROVIDER_UNKNOWN`), and an optional method the chosen provider
lacks, such as `set` on an OAuth-only provider, fails with `unsupported`.
Two providers with the same `name` throw when you build the router.

## Check it [#check-it]

Run `testCredentialProvider` from `better-supabase/testing` against a test
account or a local emulator. It checks the API version, refuses a ref for
another provider, reads a seeded token and a changed one, keeps per-user
credentials apart, revokes, and, with `inbound`, accepts a signed request
and refuses a tampered one:

```ts title="tests/aws-credentials.test.ts"
import { testCredentialProvider } from "better-supabase/testing";
import { it } from "vitest";

it("conforms", () =>
  testCredentialProvider(awsCredentials, {
    ref: { provider: "aws-secrets", secret: "conformance" },
    userRef: { provider: "aws-secrets", secret: "conformance", scope: "user" },
    seed: (ref, subject, value) => putSecret(ref, subject, value),
  }));
```

A table with a `credential_ref` column also needs a lifecycle step that
calls `revoke` when its row goes away, for example when a tenant is
deleted. `revokeIfConfigured(provider, ref, { subject, tenant })` does
that for code where the provider is optional: it resolves `false` without
a provider, when the provider can't revoke the ref, or when the ref belongs
to another tenant.

# Credentials

> Third-party tokens behind a credential reference, resolved by a CredentialProvider over Supabase Vault or Vercel Connect.

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

Blocks that call other services (a connector, an MCP server, a Slack bot)
never store a token in a table. A row stores a `credential_ref jsonb` that
names the credential, and a `CredentialProvider` from
`better-supabase/credentials` turns the ref into a token when the server
needs one. Revoking a connection is a provider call, not a schema change,
and a leaked row reveals no secret.

```ts
// A ref stored in a row
{ "provider": "vault", "secret": "github", "scope": "user" }
```

| Provider                     | Subpath                          | Where the secret lives                               |
| ---------------------------- | -------------------------------- | ---------------------------------------------------- |
| `vaultCredentials(options)`  | `better-supabase/credentials`    | Supabase Vault, through the `credentials` SQL module |
| `vercelConnectCredentials()` | `better-supabase/vercel-connect` | Vercel Connect connectors                            |

## Supabase Vault [#supabase-vault]

```bash
pnpm better-supabase sql add credentials
```

The module adds three `security definer` functions that only
`service_role` can call: `credential_get(provider, name)`,
`credential_set(provider, name, secret, description)` and
`credential_delete(provider, name)`. Secrets are stored in Vault under the
name `bs:cred:<provider>:<name>`. The module needs the Vault extension,
which Supabase projects have by default.

```ts title="src/lib/server.ts"
import { sqlTransport, vaultCredentials } from "better-supabase/credentials";

export const bs = createServer(betterSupabase, {
  credentials: vaultCredentials({
    transport: sqlTransport(postgres.asService()),
  }),
});
```

Handlers read it as `ctx.credentials`:

```ts
const subject = subjectFor(ctx);
if (ctx.credentials === undefined || subject === undefined)
  return unauthorized();
const token = await ctx.credentials
  .getToken(row.credential_ref, { subject })
  .orThrow();
await fetch(url, { headers: token.headers });
```

`subjectFor(ctx)` is the signed-in user (or the user behind an API key),
the app itself for a service request, or `undefined` for an anonymous one.
`ctx.credentials` is set only when `createServer` gets a provider. A ref with `scope: "user"` holds one
secret per user; reading it without a user subject fails with the hint
`CREDENTIAL_SUBJECT_REQUIRED`, and a missing secret is `not_found` with
`CREDENTIAL_NOT_FOUND`. `token.headers` is `authorization: Bearer <token>`
unless the ref sets `header` and `scheme` (`scheme: null` sends the raw
token). Tokens are cached for `cacheMs` (60 seconds); `set` and `revoke`
clear the cache.

Store a secret with `credentials.set(ref, value, { subject })`, and remove
it with `credentials.revoke(ref, { subject })`.

### Tenant refs [#tenant-refs]

A ref on a tenant's row carries that tenant: `{ "provider": "vault",
"secret": "github", "tenant": "<organization id>" }`. Build one with
`tenantCredentialRef(tenant, ref)`. Vault stores it as
`tenant/<tenant>/<secret>`, apart from every other tenant's secrets and the
app's own, so a tenant admin who picks a ref can't name another tenant's
credential. The connectors, AI providers and workflow builder blocks
reject a ref whose `tenant` isn't the row's tenant, in SQL and before any
provider call, with `forbidden` and the hint `CREDENTIAL_REF_FOREIGN`.
They never resolve or revoke such a ref. Only the service role may store
a ref without a tenant on a tenant's row; `credentialRefInTenant(ref,
tenant)` is the same check for your own tables.

A provider without tenant namespaces refuses a tenant ref for the app
subject. `vercelConnectCredentials` does this: Vercel Connect would return
the app's own installation token for every tenant, so it resolves a tenant
ref only for a user subject.

### Inbound requests [#inbound-requests]

A ref with `inbound` verifies requests the other service sends:
`standard-webhooks` (Standard Webhooks signatures), `hmac-sha256` (a hex
HMAC in `x-hub-signature-256`, or `signatureHeader`) or `shared-secret`.
`credentials.verifyInbound(request, ref)` resolves with `true` when the
request is signed with the stored secret. It reads a clone, so the handler
can still read the body.

## Vercel Connect [#vercel-connect]

[Vercel Connect](https://vercel.com/docs/connect) holds the OAuth tokens of
connectors you install on a Vercel project. The adapter loads
`@vercel/connect` the first time it is used:

```bash
pnpm add @vercel/connect
```

```ts title="src/lib/server.ts"
import { vercelConnectCredentials } from "better-supabase/vercel-connect";

export const bs = createServer(betterSupabase, {
  credentials: vercelConnectCredentials(),
});
```

A ref names the connector: `{ "provider": "vercel-connect", "connector":
"github", "scopes": ["repo"] }`. On Vercel the adapter authenticates with
the deployment's OIDC token; elsewhere pass `vercelToken`. When the user
hasn't authorized the connector yet, `getToken` fails with `forbidden` and
the hint `CREDENTIAL_AUTHORIZATION_REQUIRED`; call
`startAuthorization(ref, { subject, redirectUri })` and send the user to the URL
it returns.

## Your own provider [#your-own-provider]

A `CredentialProvider` has `apiVersion: 1`, a `name`, `capabilities`
(`userSubjects`, `authorization`, `revoke`, `inbound`), `getToken(ref,
options)` and `revoke(ref, options)`, plus `startAuthorization`,
`completeAuthorization` and `verifyInbound` when its capabilities say so.
Every method returns a `Result`, never throws for a provider error, and
puts the token only in the returned value. Run `testCredentialProvider` from
`better-supabase/testing` against it; it throws a `ConformanceError` that
lists every failed check:

```ts
import { testCredentialProvider } from "better-supabase/testing";
import { it } from "vitest";

it("conforms", () =>
  testCredentialProvider(provider, {
    ref: { provider: "mine", name: "github" },
    seed: (ref, subject, value) => store.put(ref, subject, value),
  }));
```

# Document formats

> Render the API model to a standard better-supabase does not ship, such as a Postman collection or a GraphQL schema, with defineDocumentFormat and testDocumentFormat.

Source: https://bettersupabase.com/docs/extending/document-formats

OpenAPI, AsyncAPI and Arazzo are document formats: each one lowers the
version-neutral API model that `defineApi` builds to the documents of a
standard. The same contract is public, so a package or an app can add a
format of its own and render it next to the built-in ones, with the same
`transform`, overlays, diagnostics and CLI.

## The contract [#the-contract]

```ts
interface DocumentFormat<Id extends string, V extends string, Doc> {
  apiVersion: 1;
  id: Id;
  // Each version with the exact version or commit it follows.
  versions: Record<V, { pin: string; status: "stable" | "legacy" | "preview" }>;
  defaultVersion: V;
  // Pure: reads the model and returns a new document.
  lower(model: ApiModel, version: V, ctx: LowerContext): Doc;
  // Checks a schema cannot express (unique ids, resolvable references).
  lint?(doc: Doc, version: V): readonly SpecDiagnostic[];
  fileName(version: V): string;
}
```

* `lower` never changes the model. One model renders every format and
  version, and better-supabase caches each lowered document.
* Report what a version cannot hold with `ctx.report` and keep going. A
  diagnostic has a stable kebab-case `code`, a `severity` (`error`,
  `warning` or `info`), a `message` and, when there is one, a JSON Pointer.
* A `preview` version follows a draft. It can never be the default, and
  every render of it reports a `preview-version` warning.

`defineDocumentFormat` fixes `apiVersion`, infers the versions and checks that
the default version exists and is not a preview.

## Example: a Postman collection [#example-a-postman-collection]

A JSON format with one version. Each operation becomes a request; the base
URL is a collection variable, so the same collection works against every
environment.

```ts title="src/lib/formats/postman.ts"
import type { ApiModel } from "better-supabase/spec";
import { defineDocumentFormat } from "better-supabase/spec";

const SCHEMA =
  "https://schema.getpostman.com/json/collection/v2.1.0/collection.json";

export const postmanFormat = defineDocumentFormat({
  id: "postman",
  versions: { "2.1": { pin: "2.1.0", status: "stable" } },
  defaultVersion: "2.1",
  lower(model: ApiModel, _version, ctx) {
    if (model.servers.length === 0)
      ctx.report({
        code: "postman-server-missing",
        severity: "info",
        message: "The API lists no server; set the baseUrl variable by hand",
        pointer: "/variable/0",
      });
    return {
      info: { name: model.info.title, schema: SCHEMA },
      variable: [{ key: "baseUrl", value: model.servers[0]?.url ?? "" }],
      item: model.operations.map((operation) => ({
        name: operation.summary ?? operation.id,
        request: {
          method: operation.method.toUpperCase(),
          url: {
            raw: `{{baseUrl}}${operation.path.replaceAll(/\{(\w+)\}/g, ":$1")}`,
            host: ["{{baseUrl}}"],
            path: operation.path
              .split("/")
              .filter(Boolean)
              .map((part) => part.replace(/^\{(\w+)\}$/, ":$1")),
          },
        },
      })),
    };
  },
  fileName: () => "postman.json",
});
```

## Example: a GraphQL schema [#example-a-graphql-schema]

A text format. `lower` returns a string, and `lint` checks the output. Object
schemas become types; a property type the mapping does not know is reported
and left out instead of guessed.

```ts title="src/lib/formats/graphql.ts"
import type { ApiModel } from "better-supabase/spec";
import { defineDocumentFormat } from "better-supabase/spec";

const SCALARS: Record<string, string> = {
  string: "String",
  integer: "Int",
  number: "Float",
  boolean: "Boolean",
};

export const graphqlFormat = defineDocumentFormat({
  id: "graphql-sdl",
  versions: { "2021": { pin: "October 2021", status: "stable" } },
  defaultVersion: "2021",
  lower(model: ApiModel, _version, ctx) {
    const types: string[] = [];
    for (const [name, schema] of Object.entries(model.schemas)) {
      if (!name.endsWith("Row") || typeof schema.properties !== "object")
        continue;
      const fields: string[] = [];
      for (const [field, property] of Object.entries(schema.properties)) {
        const kinds: string[] = [property.type].flat();
        const kind = kinds.find((type) => type !== "null");
        const scalar = kind === undefined ? undefined : SCALARS[kind];
        if (!scalar) {
          ctx.report({
            code: "graphql-type-unsupported",
            severity: "warning",
            message: `${name}.${field} has no GraphQL scalar; it is left out`,
            pointer: `/components/schemas/${name}/properties/${field}`,
          });
          continue;
        }
        fields.push(
          `  ${field}: ${scalar}${kinds.includes("null") ? "" : "!"}`,
        );
      }
      types.push(`type ${name.slice(0, -3)} {\n${fields.join("\n")}\n}`);
    }
    return `${types.join("\n\n")}\n`;
  },
  lint(document) {
    return document.includes("type ")
      ? []
      : [
          {
            code: "graphql-schema-empty",
            severity: "warning",
            message: "The schema defines no type",
          },
        ];
  },
  fileName: () => "schema.graphql",
});
```

## Rendering a format [#rendering-a-format]

Render it from the API like a built-in format. `transform` and overlays work
the same way; overlays need a JSON document.

```ts title="src/lib/api.ts"
const { document, diagnostics, fileName } = api.render(postmanFormat);
```

The `better-supabase spec` command picks formats up from the entry file:
export them as `formats` next to `api`, then select one with
`--format postman`.

```ts title="src/lib/spec.ts"
export { api } from "./api";
export const formats = [postmanFormat, graphqlFormat];
```

## Testing a format [#testing-a-format]

`testDocumentFormat` from `better-supabase/testing` checks the contract like
the other [conformance kits](/docs/extending/conformance): it resolves to a
report when every check passes and rejects with a `ConformanceError` that
lists every failed check otherwise. It lowers every version (or the ones you
pass) of a sample model that has routes, a stream, a webhook, a Realtime
channel, a workflow and bearer security, or of the model you pass.

```ts title="src/lib/formats/postman.test.ts"
import { testDocumentFormat } from "better-supabase/testing";
import { it } from "vitest";
import { api } from "../api";
import { postmanFormat } from "./postman";

it("implements DocumentFormat v1", async () => {
  await testDocumentFormat(postmanFormat);
});

it("renders the app's own model", async () => {
  await testDocumentFormat(postmanFormat, {
    model: api.model,
    versions: ["2.1"],
  });
});
```

The kit checks that the format:

* targets API v1 with an id;
* lists versions with a pin and a known status, and defaults to a listed
  version that is not a preview;
* lowers every version without changing the model, also when the model is
  frozen;
* renders the same document and diagnostics twice;
* renders plain JSON or a string;
* reports diagnostics with a kebab-case code, a known severity, a message
  and a JSON Pointer;
* finds no error with its own `lint` in a document whose lowering reported
  none;
* names a bare file for each version, the same on every call.

# Events

> Observe queries, mutations, errors, auth and refreshes with betterSupabase.on.

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

```ts
const off = betterSupabase.on("mutation", ({ table, kind, rows, context }) => {
  metrics.increment(`db.${table}.${kind}`, rows.length);
});

off();
```

`betterSupabase.on` returns a function that unsubscribes. Handlers are observers: they
can't change a result, and an error they throw goes to the
[`Logger`](/docs/extending/interfaces#logger) instead of the caller.

Subscribe once, at module scope or when a long-lived service starts. A
handler registered per request or per component render is never removed
unless you call `off()`, so the hub grows with every call. Outside
production, the logger warns once when one event has more than 50 handlers.

| Event      | Payload                                                                                                                                 |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `query`    | `table`, `operation`, `ok`, `durationMs`, `rows`, `truncated` (an unbounded read hit [`maxRows`](/docs/repository/pagination#row-caps)) |
| `mutation` | `table`, `kind` (`insert`, `upsert`, `update`, `delete`), `intent`, a copy of the app-cased `rows`, `keys`, `tenant`, `context`         |
| `error`    | `table`, the `DbError`                                                                                                                  |
| `auth`     | `source` (`bearer`, `cookie`, `none`), `ok`, `userId`, `rawSource` (a custom resolver's name), `reason` (why there is no user)          |
| `refresh`  | `ok`, `shared` (joined an in-flight refresh), `durationMs`                                                                              |
| `block`    | `type` (`support.started`, `organization.created`, ...), a copy of `data`, `subject`, `tenant`, `actorId`, `time`, `context`            |

`mutation` rows are a copy of what the database returned, so a listener
cannot change the caller's result. `delete(id)` returns the primary key, so
row-level cache tags and `row.deleted` CloudEvents work for deletes too.

`intent` is what the caller asked for: a soft delete runs as an `update`
(`kind`) with `intent: "softDelete"`. `keys` holds the primary keys of the
changed rows when they are known, from the returned rows or from a `where` on
the primary key, so writes that return nothing (soft deletes,
`returning: false`) still invalidate the right rows. `tenant` is
`context.tenant` or the tenant the `tenant()` plugin resolved from the claims.

Listeners are per runtime: register them once, next to `defineSupabase`, not
per request. Cache invalidation (`betterSupabase.cache`), CloudEvents
(`forwardMutations`) and OpenTelemetry metrics are built on these events.

## Diagnostics [#diagnostics]

`diagnostics: true` writes one debug record to the
[`Logger`](/docs/extending/interfaces#logger) for every `query`, `error`,
`refresh` and `auth` event, following the Supabase SDK
[diagnostic logging capability](https://github.com/supabase/sdk/blob/capability-matrix/v1.14.0/packages/capability-matrix/specs/client/observability/diagnostic_logging.md):

```ts
const betterSupabase = defineSupabase(schema, {
  diagnostics: process.env.NODE_ENV !== "production",
  logger: pino(),
});
// debug: "select customers ok in 14ms" { table, operation, ok, durationMs, rows, truncated }
// debug: "conflict error on contacts" { table, kind, status, code }
```

The records carry names, outcomes, timings and counts only. They never
contain tokens, keys, user ids, row values, filters, query strings, headers
or database error messages (a constraint message can quote the conflicting
value). The option is off by default, and with it off no handler is
registered. A logger that throws is caught like any other handler, so it
never changes a result.

## Block events [#block-events]

The blocks and the SQL modules report what they did as `block` events.
`onBlockEvent` from `better-supabase/events` listens to one type or to every
type with a prefix, and types the data for the match:

```ts
import { onBlockEvent } from "better-supabase/events";

onBlockEvent(betterSupabase, "support.*", (event) => {
  // event.type is "support.started" | "support.ended" | "support.denied"
  auditTrail.write({ type: event.type, admin: event.data.adminId });
});
```

Every type is `<entity>.<past_verb>` in snake case. A payload with a tenant
carries `organizationId`, and the subject is `<collection>/<id>` with a
kebab-case plural collection, such as `organizations/<id>` or
`support-sessions/<id>`. Each SQL module declares the events it writes, with
their subject and payload keys, and rendering fails when a module writes
anything else. Events from writers that a job, a webhook or an engine may
call again carry an idempotency key, so the outbox keeps one row.

| Type                                                                                                                                                                                                                                                                                                                       | Data                                                                                                                                |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `support.started`, `support.ended`, `support.denied`                                                                                                                                                                                                                                                                       | `sessionId`, `adminId`, `targetUserId`, `reason`, `readOnly`, `expiresAt`, `organizationId`, `denial`, `endedBy`                    |
| `organization.created`, `organization.updated`, `organization.deleted`, `organization.member_added`, `organization.member_removed`, `organization.member_left`, `organization.member_suspended`, `organization.member_resumed`, `organization.role_changed`, `organization.ownership_transferred`, `organization.switched` | `organizationId`, `userId`, `role`, `previousRole`                                                                                  |
| `organization.deletion_requested`, `organization.deletion_cancelled`, `organization.purged`                                                                                                                                                                                                                                | `organizationId`, `userId`, `purgeAfter`                                                                                            |
| `organization.domain_added`, `organization.domain_updated`, `organization.domain_verified`, `organization.domain_removed`                                                                                                                                                                                                  | `organizationId`, `domainId`, `domain`, `userId`                                                                                    |
| `sso_provider.registered`, `sso_provider.unregistered`                                                                                                                                                                                                                                                                     | `organizationId`, `providerId`, `domains`, `userId`                                                                                 |
| `scim_user.saved`, `scim_user.deleted`, `scim_group.saved`, `scim_group.deleted`                                                                                                                                                                                                                                           | `organizationId`, `scimUserId` or `scimGroupId`                                                                                     |
| `invitation.created`, `invitation.resent`, `invitation.updated`, `invitation.accepted`, `invitation.declined`, `invitation.revoked`                                                                                                                                                                                        | `invitationId`, `organizationId`, `email`, `role` (never the token)                                                                 |
| `waitlist.approved`, `waitlist.rejected`                                                                                                                                                                                                                                                                                   | `entryId`, `email`                                                                                                                  |
| `invite_code.created`, `invite_code.revoked`                                                                                                                                                                                                                                                                               | `codeId`, `organizationId`, `prefix`, `role` (never the code)                                                                       |
| `api_key.created`, `api_key.revoked`, `api_key.rotated`                                                                                                                                                                                                                                                                    | `keyId`, `organizationId`, `userId`, `name`, `publicId`, `previousKeyId` (never the secret)                                         |
| `organization_setting.updated`, `organization_setting.reset`, `platform_setting.updated`, `platform_setting.reset`                                                                                                                                                                                                         | `organizationId`, `key`                                                                                                             |
| `flag.saved`, `flag.deleted`, `flag.override_set`                                                                                                                                                                                                                                                                          | `key`, `variant`, `organizationId`, `userId`                                                                                        |
| `announcement.saved`, `announcement.deleted`                                                                                                                                                                                                                                                                               | `announcementId`                                                                                                                    |
| `credential.set`, `credential.deleted`                                                                                                                                                                                                                                                                                     | `provider`, `name` (never the secret)                                                                                               |
| `notification.created`, `notification.delivered`, `notification.failed`                                                                                                                                                                                                                                                    | `notificationId`, `organizationId`, `type`, `recipientIds`, `channel`, `error`                                                      |
| `webhook.delivered`, `webhook.failed`, `webhook.disabled`, `webhook.secret_rotated`                                                                                                                                                                                                                                        | `endpointId`, `organizationId`, `deliveryId`, `eventType`, `status`, `attempt`, `error`, `failingSince`                             |
| `incoming_webhook.created`, `incoming_webhook.updated`, `incoming_webhook.enabled_set`, `incoming_webhook.token_rotated`, `incoming_webhook.secret_rotated`, `incoming_webhook.deleted`                                                                                                                                    | `organizationId`, `endpointId`, `name`, `verify`, `enabled`, `secretRotated`                                                        |
| `billing.customer_linked`, `billing.checkout_completed`, `billing.subscription_created`, `billing.subscription_updated`, `billing.subscription_deleted`, `billing.seats_synced`                                                                                                                                            | `organizationId`, `customerId`, `subscriptionId`, `status`, `quantity`, `previousQuantity`, `stripeEventId`                         |
| `comment.created`, `comment.mentioned`, `comment.deleted`                                                                                                                                                                                                                                                                  | `commentId`, `organizationId`, `subjectType`, `subjectId`, `authorId`, `parentId`, `mentionIds`                                     |
| `attachment.uploaded`, `attachment.scanned`                                                                                                                                                                                                                                                                                | `attachmentId`, `organizationId`, `subjectType`, `subjectId`, `uploadedBy`, `mimeType`, `size`, `status`                            |
| `object.uploaded`                                                                                                                                                                                                                                                                                                          | `bucket`, `path`                                                                                                                    |
| `data_export.requested`, `data_export.completed`, `data_export.failed`                                                                                                                                                                                                                                                     | `exportId`, `subject`, `organizationId`, `userId`, `requestedBy`, `files`, `expiresAt`, `error`                                     |
| `push.device_registered`, `push.device_unregistered`                                                                                                                                                                                                                                                                       | `deviceId`, `userId`, `platform`, `provider`                                                                                        |
| `inbox_conversation.opened`, `inbox_conversation.assigned`, `inbox_conversation.resolved`, `inbox_conversation.reopened`, `inbox_message.received`                                                                                                                                                                         | `conversationId`, `organizationId`, `inboxId`, `contactId`, `assigneeId`, `previousAssigneeId`, `status`, `messageId`               |
| `workflow_run.completed`, `workflow_run.failed`, `workflow_run.cancelled`                                                                                                                                                                                                                                                  | `runId`, `organizationId`, `engine`, `externalId`, `definition`, `status`, `error`                                                  |
| `workflow.published`, `workflow_alert.triggered`                                                                                                                                                                                                                                                                           | `organizationId`, `definitionId`, `versionId`, `version`, `alertId`, `definition`, `onEvent`, `channel`, `runId`, `status`, `error` |
| `connector.saved`, `connector.deleted`, `connector.fingerprint_decided`, `connector_grant.created`, `connector_grant.revoked`                                                                                                                                                                                              | `organizationId`, `serverId`, `name`, `fingerprint`, `approved`, `grantId`, `userId`                                                |
| `agent.saved`, `agent.published`, `agent.deleted`, `agent.installed`, `agent.uninstalled`                                                                                                                                                                                                                                  | `organizationId`, `agentId`, `slug`, `visibility`, `userId`                                                                         |
| `ai_provider_key.saved`, `ai_provider_key.deleted`                                                                                                                                                                                                                                                                         | `organizationId`, `keyId`, `provider`, `name` (never the key)                                                                       |
| `ai_chat.shared`, `ai_chat.share_revoked`, `ai_chat_message.completed`                                                                                                                                                                                                                                                     | `chatId`, `organizationId`, `ownerId`, `shareId`, `leafId`, `messageId`, `model`, `status`                                          |
| `ai_tool_policy.set`, `ai_tool_approval.decided`                                                                                                                                                                                                                                                                           | `organizationId`, `tool`, `policy`, `chatId`, `approvalId`, `decision`                                                              |
| `audit.revealed`                                                                                                                                                                                                                                                                                                           | `organizationId`, `entries`                                                                                                         |

`BLOCK_EVENT_RENAMES` from `better-supabase/events` maps the 0.5 names to the
current ones (`BLOCK_EVENT_RENAMES[type] ?? type`), for consumers that still
receive both.

Block events follow the same rules as the others: the data is a copy, and a
handler can't change what the block does. To decide something, pass the module's
policy callback instead (below).

`forwardBlockEvents(betterSupabase, sink, { source, types })` sends them to an
[`EventSink`](/docs/extending/interfaces#eventsink) as CloudEvents
(`dev.better-supabase.organization.created`, the tenant as `partitionkey`, the actor as
`data.actorId`), and `traceBlockEvents(betterSupabase)` from `better-supabase/otel`
records them on the active span. These events are in-process: when one must
not be lost, install the `outbox` module, and the SQL modules write the same
event in the transaction that caused it.

`dataschema` sets the CloudEvents `dataschema` attribute, the URI (or `$id`)
of the JSON Schema the event's `data` conforms to. It is accepted by
`forwardBlockEvents`, `blockCloudEvent`, `forwardMutations` and
`toCloudEvents`. A string applies to every event; a function receives
`{ type, table }` (`type` with the configured prefix, `table` set for row
events) and returns a URI, or `undefined` to leave the attribute out.
Without the option, events carry no `dataschema`:

```ts
forwardBlockEvents(betterSupabase, sink, {
  source: "/crm",
  dataschema: ({ type }) => `https://crm.example.com/schemas/${type}.json`,
});
```

## Policy callbacks [#policy-callbacks]

Where a block has a decision the app may want to make (who may start a support
session, who may invite, whether to deliver a notification, which webhook
URLs are allowed), it takes a policy callback such as `authorize`,
`canInvite`, `shouldDeliver` or `allowUrl`. Every policy follows the same
rules:

* It can be sync or async.
* Only `true` allows. `false`, any other value, a throw or a rejection
  denies, so a bug in a policy fails closed.
* Without the callback, the module's documented default applies.
* A denial emits the module's `*.denied` or `*.failed` event with the reason.

## Attribute names [#attribute-names]

Spans and events use fixed attribute names (`BLOCK_ATTRIBUTES` from
`better-supabase/events`), so dashboards don't depend on a block's internals:

| Attribute                             | Value                         |
| ------------------------------------- | ----------------------------- |
| `better_supabase.block.event`         | the event type                |
| `better_supabase.tenant`              | the tenant id                 |
| `better_supabase.actor.id`            | the user who caused it        |
| `better_supabase.support.session_id`  | the support session           |
| `better_supabase.organization.id`     | the organization              |
| `better_supabase.invitation.id`       | the invitation                |
| `better_supabase.notification.kind`   | the notification kind         |
| `better_supabase.webhook.endpoint_id` | the outgoing webhook endpoint |

## SQL hooks [#sql-hooks]

SQL modules call optional app functions around their work, for example
`after_organization_create(organization uuid, user_id uuid)`. A hook runs in the same
transaction when a function with that name and argument list exists in
`public`, and is skipped otherwise. Raise an exception in a `before_*` hook
to refuse the operation. Point a module at another schema or function with
`sql.modules.<module>.hooks`:

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      organizations: {
        hooks: {
          schema: "app",
          functions: { after_organization_create: "private.seed_organization" },
        },
      },
    },
  },
});
```

Hooks are named `before_<entity>_<action>` and `after_<entity>_<action>`;
`notification_audience` and `after_notify` keep the names they shipped with.
Each module's page lists its hooks and their arguments, and
[Extending blocks](/docs/extending/blocks#hooks-in-sql) lists them for
organizations, profiles and notifications. With the `outbox`
module installed, modules also write their events with `emit_event` in the
same transaction; `sql.modules.<module>.events: false` turns that off for one
module.

# 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() });
```

# Writing plugins

> Plugin API v1, its hooks, and typed repository extensions.

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

```ts
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 [#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 `DbError`s                    |
| `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](/docs/repository/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 [#api-descriptions]

`describe(api)` tells [`defineApi`](/docs/specs) 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.

```ts title="src/plugins/region.ts"
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](/docs/specs/diagnostics))
and builds the rest of the model. The built-in plugins describe themselves:
[`timestamps()`](/docs/plugins/timestamps), [`softDelete()`](/docs/plugins/soft-delete)
and [`tenant()`](/docs/plugins/tenant).

## Scoping helpers [#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:

```ts
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 [#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):

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

* Hooks never see or return app-cased rows in the IR; convert with the table
  metadata.
* `afterMutation` and event handlers cannot change results: they get a copy
  of the rows, and `testPlugin` checks it. Errors they throw go to the
  [`Logger`](/docs/extending/interfaces#logger), not the caller.
* Hook order depends only on `enforce` and `use()` order, never on a
  plugin's name.
* Prove a plugin with [`testPlugin`](/docs/extending/conformance). It also
  installs the plugin next to `timestamps()`, `softDelete()`, `tenant()` and
  `actor()` in both orders, and fails a `repository` hook that replaces a
  base method.
* A plugin must leave tables without its flag untouched.

# Custom repositories

> Domain methods with defineRepository and extend, and the escape hatches below them.

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

## defineRepository [#definerepository]

Put domain queries next to the table they belong to. `defineRepository`
returns a plugin that adds methods to one table only:

```ts title="src/lib/supabase/index.ts"
import { defineRepository, defineSupabase } from "better-supabase";

const base = defineSupabase(schema).use(tenant()).use(softDelete());

const customers = defineRepository(base, "customers", (repo) => ({
  active: () =>
    repo.findMany({ where: { status: "active" }, orderBy: { name: "asc" } }),
  byKvk: (kvk: string) => repo.findFirst({ where: { kvk } }),
}));

export const betterSupabase = base.use(customers);
```

```ts
const rows = await ctx.db.customers.active().orThrow();
```

`repo` is the repository as your app sees it: every plugin (tenant filters,
soft delete, `restore()`) applies, and payload types flow through. The
methods are typed on `db.customers` and don't exist on other tables.

Each table can have one `defineRepository`; the plugin is named
`repository:<table>`, and redefining a built-in method such as `findMany`
throws when the repository is first used.

## extend [#extend]

For methods that only one module needs, extend a repository in place:

```ts
const customers = ctx.db.customers.extend((repo) => ({
  withOpenInvoices: () =>
    repo.findMany({ where: { invoices: { some: { status: "open" } } } }),
}));
```

`extend` returns a new object; `ctx.db.customers` is unchanged.

## Escape hatches [#escape-hatches]

Each layer has a way out when the repository doesn't cover a query:

| Need                                     | Use                                                                                       |
| ---------------------------------------- | ----------------------------------------------------------------------------------------- |
| A PostgREST feature the repository lacks | `db.$client`, the supabase-js client (database column names)                              |
| A database function                      | `db.$rpc(name, args, { returns })`                                                        |
| Raw SQL under RLS                        | `ctx.sql` from [`/postgres`](/docs/auth/postgres)                                         |
| A different backend                      | an [`Executor`](/docs/extending/interfaces#executor) passed to `betterSupabase.connect()` |
| Rewriting queries everywhere             | a [plugin](/docs/extending/plugins) with `transformQuery`                                 |

# Stability

> What counts as public API, how it is kept stable and how deprecations work.

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

## Public API [#public-api]

The public API is every export of every subpath in `package.json`, the CLI
commands and flags, the config file, the JSON Schemas in `schemas/`, the
doctor codes and the generated module's shape.

Two tests guard it:

* **Export snapshot.** `api/exports.json` lists the value and type exports of
  every subpath. A change shows up in review as a diff of that file.
* **Type tests** for every extension interface check that the first-party
  implementations still satisfy it.

## Versioning [#versioning]

better-supabase follows semver. Until 1.0, breaking changes ship in minor
versions and are called out in the changelog.

Plugins and extension interfaces carry `apiVersion: 1`. A breaking change to
their contract needs a new `apiVersion`; `use()` rejects plugins built for a
version it doesn't support, so the failure is immediate instead of subtle.

The config is a strict object: the CLI rejects a field it doesn't know, in
the config and in an `authorization` provider, instead of ignoring it. A
provider built for another `apiVersion` gets one error naming both versions,
not a list of the fields that changed. New optional fields join an
`apiVersion` in a minor release; a field that changes meaning or becomes
required needs a new `apiVersion`.

## Deprecations [#deprecations]

1. The API is marked `@deprecated` in its TSDoc, with the replacement. Editors
   strike it through.
2. It keeps working for at least one minor release (after 1.0: until the next
   major).
3. The changelog and this site's migration notes describe the replacement.
4. It is removed, and `api/exports.json` shows the removal.

Doctor codes are never reused: a retired check keeps its code reserved.
BS101, BS102, BS104, BS105, BS201, BS202 and BS203 are retired; Supabase's
own advisors report those problems now, as BS100 and BS200.
A new check gets a new code and ships in a minor release, because it can make
a passing run report findings: BS205 to BS209, BS211 and BS212 (RLS
performance, statistics and plans) arrived that way, and so did BS213
(API roles that can write the columns granting access), BS214 (permissions the provider doesn't fully answer in Storage and Realtime
policies), BS407 (two authorization hooks), BS408 (the provider's memberships
for entitlements) and BS409 (the authorization provider and the config
disagree), BS410 (HTTP auth hooks), BS411 (the provider access model can't use
the provider, in 0.5.1, because the configurations it reports rendered
functions that call missing helpers), and
BS307 to BS314 (custom block contracts, the tenant claim, deprecated block
symbols, duplicate block triggers, SQL modules behind their version, exposed
block schemas, rate limits not wired to PostgREST and migration-only block
options), BS315 (tables without an audit trigger), BS320 (tables without
the session policy), BS322 (audit registrations of dropped tables),
BS323 (module event triggers missing from the database or the migrations)
and BS324 (a roles table shared by tenant and platform roles without a
`where` on both sides), BS325 (the audit log readable only as the service
role while the app lists it as a user), BS326 (`deleteAccount` or `admin`
without `SUPABASE_SECRET_KEY` in the env files) and BS327 (`requireAal('aal2')`
without MFA enroll and verify in `config.toml`).

## SQL module objects and claims [#sql-module-objects-and-claims]

The SQL modules' database objects are public API too: the functions, tables,
columns and triggers each module creates, the claim names its functions read
(`tenant_id`, `tenant_role`, `is_platform`), and the
[access contract](/docs/blocks/access) (`can()`, `tenant_ids_with()`,
`is_platform()`). Apps call them from policies and their own functions, so a
rename breaks the database, not the build. They follow these rules:

1. Each module has a version, recorded in its file's `@bs-module` line and in
   `better_supabase.modules`. A change the schema diff can't follow (a
   renamed column, a backfill) raises the version and ships a forward step
   for `better-supabase sql upgrade`.
2. A renamed function keeps a wrapper under the old name for at least one
   minor release, with a `comment on function ... is 'deprecated: use X'`.
   A renamed table keeps a view under the old name, with the old column
   names, for the same period. A renamed claim is read under both names.
   A renamed column is renamed in place by the upgrade step, so code that
   still uses the old name fails until it changes; doctor finds it first.
3. Doctor reports code that still uses a deprecated or removed symbol
   (BS309), and a module behind its version (BS311).
4. A removal is listed in the changelog with the replacement.

Names you map in `sql.modules.<module>` (tables, columns, statuses) and claim names
you set in `claims` are yours: the block never renames them. Options that only
match an existing schema are accepted in adopt mode only, and doctor warns
about them (BS314); a later release can drop one after a deprecation in the
changelog.

## Subpaths [#subpaths]

Every subpath is public API with the same guarantees, including
`better-supabase/plugins/rules` (the `rules()` plugin, its rule names and
presets) and `better-supabase/lint` (lint rule ids and their options).
Adding a rule to a preset can make a passing app report new violations, so
it ships in a minor release with a changelog entry.

## Pinned upstreams [#pinned-upstreams]

Some output depends on Supabase projects. They're pinned so an upstream
release never changes your generated code or doctor report by itself:

* **`@supabase/postgrest-typegen`** is an optional peer of the CLI. The
  peer accepts `>=0.4.0 <0.5`, and the CLI is tested against 0.4.0; `gen`
  prints a notice when another release is installed.
  `database.types.ts` matches `supabase gen types` for that version, apart
  from differences the CI test lists (0.4.0 adds `ComputedFields`, which
  Supabase CLI 2.119 doesn't write yet). Bumping it is a changelog entry.
* **splinter** (the advisor lints) is fetched at a pinned commit and
  checked against a SHA-256 hash when doctor runs locally. It's never
  bundled.
* **`@supabase/config`** is an optional peer with a version range. Without
  it, `config.toml` is read with a built-in parser that supports what
  better-supabase needs.

# For AI agents

> Agent Skills, llms.txt, Markdown pages and a docs MCP server that help coding agents use better-supabase correctly.

Source: https://bettersupabase.com/docs/for-ai-agents

## Agent Skills [#agent-skills]

The package ships [Agent Skills](https://agentskills.io) that teach coding
agents the conventions in these docs:

| Skill                     | Covers                                                                                                                   |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `better-supabase`         | Queries, writes, Results, cursors, Temporal values, the codegen loop after a schema change, and upgrades                 |
| `better-supabase-api`     | Adapters, `allow` and `scopes`, REST resources, MCP tools, AI chat routes, jobs and their cleanup, idempotency, webhooks |
| `better-supabase-auth`    | Sessions, typed claims, OAuth clients and agents, `checkSession`, claim changes, authorization providers                 |
| `better-supabase-testing` | `asUser`, `signLocalJwt`, delegated-token tests, Temporal in tests, typed seeds, pgTAP, doctor in CI                     |

Install them with the [`skills`](https://skills.sh) CLI, which supports
Cursor, Claude Code, Codex, OpenCode and most other agents:

```bash
npx skills add ScaleDockHQ/better-supabase
npx skills add ScaleDockHQ/better-supabase --skill better-supabase -a cursor
```

That installs the skills from the `main` branch. To install the ones that
match the version in your lockfile, use the CLI:

```bash
pnpm better-supabase skills install                 # into the agent folders the project has
pnpm better-supabase skills install --agent cursor,claude
pnpm better-supabase skills install --check         # in CI, after upgrading
```

Skills go to `.cursor/skills`, `.claude/skills` or `.agents/skills`, or to
your home directory with `--global`. Set `skills: { agents: ["cursor", "claude"] }`
in the config to pick the folders without `--agent`. Reinstall after an upgrade, and
`--check` tells you when they are stale. `--from <dir>` installs skills
from a folder instead of the package, to try a change before you publish it.

To give your users' agents a way into your app, add an
[MCP server](/docs/frameworks/mcp) that runs as the signed-in user; the
[Supabase library MCP blocks guide](/docs/guides/supabase-blocks) covers the
Supabase library's MCP server and headless app blocks.

Supabase's own [MCP server](https://supabase.com/docs/guides/getting-started/mcp)
is a different tool: it administers a project (SQL, migrations, logs, docs
search) for the people who run it. Connect an agent to it with
[`supabaseMcp`](/docs/ai-sdk/mcp#supabases-mcp-server), or serve it from your
app behind your own auth with
[`supabaseMcpHandler`](/docs/frameworks/mcp#supabases-mcp-server). Tools for
your app's users belong in your own MCP server, where they run with RLS.

In Claude Code, the repository is also a plugin marketplace. The plugin
installs the same skills and connects the [docs MCP server](#docs-mcp-server):

```bash
/plugin marketplace add ScaleDockHQ/better-supabase
/plugin install better-supabase@better-supabase
```

The repository has a Cursor plugin manifest (`.cursor-plugin/plugin.json`)
with the same skills and server.

These skills cover better-supabase. For Supabase itself (Auth, Storage, RLS
and Postgres performance), add Supabase's own skills too. The
`better-supabase` skill's schema workflow already asks for RLS, policies and
[Data API grants](/docs/guides/data-api-grants) on every new table; Supabase's
skills explain the policies in depth:

```bash
npx skills add supabase/agent-skills
```

## Docs as Markdown [#docs-as-markdown]

* [`/llms.txt`](/llms.txt) is an index of every page with absolute links to
  their Markdown, grouped by the sidebar sections, and
  [`/llms-full.txt`](/llms-full.txt) is every page as one Markdown file. Both
  also answer under `/docs`: `/docs/llms.txt` and `/docs/llms-full.txt`.
* Every page is also Markdown at its own URL plus `.md`, for example
  [`/docs/repository/filtering.md`](/docs/repository/filtering.md), and the
  docs home is [`/docs.md`](/docs.md). Requests with `Accept: text/markdown`
  get the same content at the normal URL, and each page links its Markdown
  with `<link rel="alternate" type="text/markdown">`.
* Callouts, steps, cards, tabs and prop tables come out as plain Markdown
  (blockquotes, numbered headings, link lists and tables), not as JSX.
* An unknown docs URL answers 404, so an agent can tell a missing page from a
  real one.

## Docs MCP server [#docs-mcp-server]

`https://bettersupabase.com/mcp` is a read-only MCP server (Streamable HTTP)
with three tools: `search_docs` runs a full-text search over the pages and
their headings, `get_page` returns one page as Markdown, and `list_pages`
returns the whole index. It speaks the 2026-07-28 revision and answers 2025
clients statelessly, takes no token and never sees your project. Install it
in one click in
[Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=better-supabase-docs\&config=eyJ1cmwiOiJodHRwczovL2JldHRlcnN1cGFiYXNlLmNvbS9tY3AifQ==)
or [VS Code](vscode:mcp/install?%7B%22name%22%3A%22better-supabase-docs%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fbettersupabase.com%2Fmcp%22%7D),
or add it to Cursor by hand:

```json title=".cursor/mcp.json"
{
  "mcpServers": {
    "better-supabase-docs": { "url": "https://bettersupabase.com/mcp" }
  }
}
```

or to Claude Code with
`claude mcp add --transport http better-supabase-docs https://bettersupabase.com/mcp`,
or commit it for everyone on the project in `.mcp.json`, which Claude Code
reads:

```json title=".mcp.json"
{
  "mcpServers": {
    "better-supabase-docs": {
      "type": "http",
      "url": "https://bettersupabase.com/mcp"
    }
  }
}
```

## Ask AI and browser agents [#ask-ai-and-browser-agents]

The Ask AI panel on every docs page answers from these docs. It searches the
pages and reads the ones it needs before it answers, and shows each search
and page it read.

In a browser with [WebMCP](https://webmachinelearning.github.io/webmcp/),
the docs pages register two read-only tools on `document.modelContext`:
`search_docs` and `read_page`, which returns a page's Markdown. A browser
agent can call them without reading the rendered page.

## What agents should rely on [#what-agents-should-rely-on]

* Types come from generated code. After a migration, run `better-supabase gen` and fix the errors instead of casting.
* Repository calls return a `Result`. Check `result.ok` or return the Result from a handler.
* Every JSON output of the CLI has a `$schema` URL, and `--check` flags exit
  with 1 on drift, so agents can verify their own changes.
* `better-supabase doctor` reports security and performance findings with
  stable codes, so an agent can fix them one by one and rerun it.
* Claims follow one contract: the active tenant in `tenant_id` (the
  `claims` block renames it), `memberships` as `{ scope, id, roles }`, and
  plan features in `features`. Roles, memberships and the tenant never come
  from `user_metadata` or from request input. When the config has an
  `authorization` provider whose hook owns `memberships`, don't add the
  `tenant` SQL module ([authorization providers](/docs/extending/authorization-providers)).

# Astro

> Run better-supabase as Astro middleware, guard pages, write Actions that return ActionResults, and render Storage images.

Source: https://bettersupabase.com/docs/frameworks/astro

`createAstro(betterSupabase, options)` from `better-supabase/astro` returns
the server with Astro helpers. Export its middleware:

```ts title="src/lib/server.ts"
import { createAstro } from "better-supabase/astro";
import { betterSupabase } from "./supabase";

export const bs = createAstro(betterSupabase, { signIn: "/sign-in" });
```

```ts title="src/middleware.ts"
import { bs } from "./lib/server";

export const onRequest = bs.onRequest;
```

The middleware verifies the caller, refreshes the session cookie on
navigations and form posts, and puts `db`, `bs`, `auth`, `tenant` and the
serializable `session` on `locals`. It runs around the page, so the refreshed
cookies reach the browser. On Cloudflare, `locals.runtime.env` seeds `getEnv`;
pass `platformEnv` to read the env elsewhere.

## Pages and endpoints [#pages-and-endpoints]

`bs.guard(Astro, options)` returns the refusal for a caller the options
refuse: a redirect to `signIn` or `mfa`, else Problem Details. Return it from
the frontmatter:

```astro title="src/pages/admin.astro"
---
import { bs } from "../lib/server";

const refused = await bs.guard(Astro, { roles: ["admin"] });
if (refused) return refused;
const members = await bs.locals(Astro).db.members.findMany().orThrow();
---
<ul>{members.map((member) => <li>{member.name}</li>)}</ul>
```

`bs.require(context, options)` returns the caller and its repositories, or
throws the refusal `Response`.

## Actions [#actions]

`bs.action(options, fn)` is the `handler` of `defineAction`. It checks the
caller, validates the input with any Standard Schema, and returns an
`ActionResult` (`{ ok, data, error }`) that the page can render:

```ts title="src/actions/index.ts"
import { defineAction } from "astro:actions";
import { bs } from "../lib/server";
import { NoteInput } from "../lib/schemas";

export const server = {
  addNote: defineAction({
    accept: "form",
    handler: bs.action(
      { input: NoteInput, requireTenant: true },
      (input, { db, tenant }) =>
        db.notes.create({ ...input, tenantId: tenant }),
    ),
  }),
};
```

## Images [#images]

`createImageService({ url })` is an external image service that renders public
Storage objects through Supabase image transformations and passes other
sources through. Point `image.service.entrypoint` at a file that exports it:

```ts title="src/image-service.ts"
import { createImageService } from "better-supabase/astro";

export default createImageService({ url: import.meta.env.PUBLIC_SUPABASE_URL });
```

The same URL building is available anywhere as `storageImageUrl()` from
[`better-supabase/storage`](/docs/platform/storage).

# Edge Functions

> Fetch handlers for Supabase Edge Functions, Deno, Bun and Workers.

Source: https://bettersupabase.com/docs/frameworks/edge

```ts title="supabase/functions/api/index.ts"
import { createEdge } from "better-supabase/edge";
import { betterSupabase } from "../_shared/supabase.ts";

const bs = createEdge(betterSupabase, { cors: true });

Deno.serve(
  bs.handler(async (request, { db }) => {
    const { name } = await request.json();
    return db.customers.create({ name, organizationId: "..." });
  }),
);
```

`bs.handler(fn)` returns a plain `(request) => Promise<Response>`. The handler
runs as the caller, so RLS applies, and its return value is converted the
same way as in the other adapters:

* A `Result` becomes JSON on success, or Problem Details on failure.
* `undefined` becomes `204`.
* A `Response` is sent as is.
* A thrown `DbException` becomes its Problem Details, and any other error a 500.

Anyone not in `allow` (default `['user']`) gets a 401 or 403 before your code
runs.

For a function the app calls with a typed input and output, use
[`defineFunction`](/docs/platform/edge-functions): it checks the body with a
Standard Schema and exports a contract for `functions()` in
`better-supabase/client`.

With [`.claims(schema)`](/docs/auth#typed-claims) on the definition, `auth`
in the handler is typed by the schema's output:

```ts
Deno.serve(
  bs.handler((_request, { auth }) => ({
    role: auth.kind === "user" ? auth.claims.user_role : null,
  })),
);
```

## REST resources [#rest-resources]

`bs.resources` serves the same REST routes as [Hono resources](/docs/frameworks/hono#resources)
without a router:

```ts
Deno.serve(
  bs.resources(
    {
      customers: { list: customerList },
      tags: { operations: ["list", "get"] },
    },
    { basePath: "/api" },
  ),
);
```

On Supabase, the function name is the first path segment, so `basePath` is
`/<function-name>`. The longest matching table path wins; an unknown path
answers 404.

Each entry takes the same options as a Hono resource, including `hooks`,
`actions` (typed with `defineAction` from `better-supabase/edge`) and
`permissions`. Pass `authorizer` to `createEdge` to decide them:

```ts
import { createEdge, defineAction } from "better-supabase/edge";

const bs = createEdge(betterSupabase, { cors: true, authorizer });

Deno.serve(
  bs.resources(
    {
      customers: {
        permissions: { delete: "customers.delete" },
        actions: {
          archive: defineAction({
            method: "POST",
            path: "/{id}/archive",
            handler: (ctx, { id }) =>
              ctx.db.customers.update(String(id), { status: "archived" }),
          }),
        },
      },
    },
    { basePath: "/api" },
  ),
);
```

See [Resources](/docs/specs/resources) for hooks, actions and permissions.

## Routes [#routes]

`bs.routes` serves several handlers from one function. Keys are
`"METHOD /path"` or `"/path"` (any method), with `:name` segments and a
trailing `/*`. `params` in each handler is typed from its key. A route can be
a handler or an object with the same guards as Hono's `bs.require`:

```ts
Deno.serve(
  bs.routes(
    {
      "GET /customers/:id": (_request, { db, params }) =>
        db.customers.findById(params.id),
      "POST /customers/:id/archive": {
        roles: ["admin"],
        roleClaim: "user_role",
        requireTenant: true,
        handler: (_request, { db, params }) =>
          db.customers.update(params.id, { status: "archived" }),
      },
    },
    { basePath: "/api" },
  ),
);
```

An unknown path returns 404, and a known path with another method returns 405
with an `Allow` header. A route object can also name a `permission`, which
the `authorizer` passed to `createEdge` decides with `{ type: "route" }` as
the resource; without an authorizer, the route refuses every caller.

## Background work [#background-work]

Event sink sends that a handler started (see
[event sinks](/docs/standards/events#sends-after-the-response)) are handed to
`waitUntil`, so the runtime keeps the invocation alive until they finish. On
Workers the handler uses the `ctx` that `fetch(request, env, ctx)` receives.
On Supabase, pass the runtime's function:

```ts
const bs = createEdge(betterSupabase, {
  waitUntil: (promise) => EdgeRuntime.waitUntil(promise),
});
```

## CORS [#cors]

`cors: true` answers preflights and adds the headers supabase-js sends
(`authorization`, `x-client-info`, `apikey`, `content-type`) for any origin.
Restrict it with an allow-list:

```ts
createEdge(betterSupabase, {
  cors: { origin: ["https://app.example.com"], maxAge: 600 },
});
```

A request from an origin that isn't listed gets no CORS headers, and error
responses get the same headers as successful ones. The headers come from
`withCors` in `@supabase/middleware/cors`: a preflight is an `OPTIONS`
request with `Access-Control-Request-Method`, and an allow-list adds
`Vary: Origin`. `corsConfig(options)` returns the `withCors` config for your
own pipeline.

## Entry arrays with `toEdge` [#entry-arrays-with-toedge]

`toEdge(entries, handler)` runs an `@supabase/middleware` entry array around
a handler and returns the same fetch handler shape. The host's second
argument seeds the pipeline, so on Workers `getEnv` inside the entries reads
the bindings:

```ts title="src/worker.ts"
import { withCors } from "@supabase/middleware/cors";
import { toEdge } from "better-supabase/edge";
import { createServer, withBetterSupabase } from "better-supabase/server";

const bs = createServer(betterSupabase);

export default {
  fetch: toEdge(
    [withCors({ origin: "https://app.example.com" }), withBetterSupabase(bs)],
    async (_request, ctx) =>
      Response.json(await ctx.db.notes.findMany().orThrow()),
  ),
};
```

The handler gets the contributions and the Workers execution context, and
returns a `Response`. Put `withCors` first so a 401 from the guard carries the
CORS headers too.

# Elysia

> Run withBetterSupabase around an Elysia app, with the caller's repositories derived into every route.

Source: https://bettersupabase.com/docs/frameworks/elysia

Elysia has no middleware slot that sees the response it produces, so
`toElysia(entries)` from `better-supabase/elysia` wraps the app's fetch.
`bridge.wrap` runs the entries around `app.handle`, and `bridge.context` hands
the contributions to routes:

```ts title="src/index.ts"
import { Elysia } from "elysia";
import { toElysia } from "better-supabase/elysia";
import { createServer, withBetterSupabase } from "better-supabase/server";
import { betterSupabase } from "./lib/supabase";

const bs = createServer(betterSupabase);
const bridge = toElysia([withBetterSupabase(bs, { allow: ["user"] })]);

const app = new Elysia()
  .derive(({ request }) => bridge.context(request))
  .get("/notes", ({ db }) => db.notes.findMany().orThrow());

export default { fetch: bridge.wrap((request) => app.handle(request)) };
```

A guard refusal answers 401 or 403 Problem Details before Elysia routes the
request, and the route's response passes back through the entries, so
refreshed cookies and `bs-primary-until` reach it.

`guard(bridge, options)` from `better-supabase/elysia` is a `beforeHandle`
hook that refuses callers per route, with the options every adapter takes
(`allow`, `aal`, `scopes`, `roles`, `requireTenant`, `permission`,
`authorize`, `signIn`, `mfa`). A `permission` is decided by the guard's
`authorizer` (see [Authorizers](/docs/extending/authorizers)); without one,
the route refuses every caller:

```ts
import { guard, problemOnError } from "better-supabase/elysia";

const app = new Elysia()
  .onError(problemOnError())
  .derive(({ request }) => bridge.context(request))
  .get("/reports", ({ db }) => db.reports.findMany().orThrow(), {
    beforeHandle: guard(bridge, { permission: "reports:read", authorizer }),
  });
```

`bridge.context(request)` throws for a request that didn't come through
`bridge.wrap`, so a route served another way fails closed instead of running
without a caller. Serve the app with the wrapped fetch, not `app.listen`.

# Expo Router

> Server loaders, API routes and middleware that verify the caller and run as them.

Source: https://bettersupabase.com/docs/frameworks/expo

```ts title="src/lib/supabase/server.ts"
import { createExpo } from "better-supabase/expo";
import { betterSupabase } from "./index";

export const bs = createExpo(betterSupabase);
```

`createExpo(betterSupabase)` is [`createServer`](/docs/auth/server) plus the
pieces Expo Router's server output needs. It reads the bearer token first and
the session cookie second, and verifies the JWT locally. Install
`expo-server` (55 or later) next to `better-supabase`, and turn on the server
features in `app.json`. On Expo SDK 57 and earlier, that takes the three
`unstable_` options of the `expo-router` plugin:

```json title="app.json"
{
  "expo": {
    "web": { "bundler": "metro", "output": "server" },
    "plugins": [
      [
        "expo-router",
        {
          "unstable_useServerDataLoaders": true,
          "unstable_useServerMiddleware": true,
          "unstable_useServerRendering": true
        }
      ],
      "expo-secure-store"
    ]
  }
}
```

`unstable_useServerRendering` renders web routes on the server per request,
so a loader sees the caller's cookie; without it, web routes are rendered at
build time and loaders run without a request.

Expo SDK 58 needs no opt-in: with `web.output: "server"`, data loaders,
middleware and server rendering are on, and the three `unstable_` options are
deprecated and have no effect. Remove them when you upgrade; the rest of
`app.json` stays the same. `expo-secure-store` is the
plugin for [`secureStorage`](/docs/frontend/react-native) on the device; add
`expo-notifications` too when you use the [push block](/docs/blocks/push).

## Loaders [#loaders]

`bs.loader(fn, options)` returns a route `loader` that runs `fn` as the
caller, so RLS applies to every read:

```tsx title="src/app/customers/index.tsx"
import { useLoaderData } from "expo-router";
import { bs } from "../../lib/supabase/server";

export const loader = bs.loader(
  ({ db }) => db.customers.findMany({ select: ["id", "name"] }).orThrow(),
  { allow: ["user"] },
);

export default function Customers() {
  const customers = useLoaderData<typeof loader>();
  // ...
}
```

* A caller `allow` refuses throws a `StatusError` (401 or 403) that Expo
  Router answers.
* A `DbException` from `.orThrow()` becomes a `StatusError` with the error's
  status, so a missing row answers 404 with the Problem Details as the body.
* During static rendering there is no request, so the loader throws. Read
  public data without a caller in a loader built with `createStaticLoader`
  from `expo-server`.
* Loader data is serialized to JSON. Select the columns the screen shows, and
  convert Temporal values to strings before returning them.

`bs.request(request, options)` returns the same server context for code that
isn't a loader.

### With expo-server's loader helpers [#with-expo-servers-loader-helpers]

`bs.loader` returns an `expo-server` `LoaderFunction`, so it works anywhere a
loader does. When a route already uses `createServerLoader`, call
`bs.request` inside it instead:

```tsx title="src/app/customers/[id].tsx"
import { createServerLoader } from "expo-server";
import { bs } from "../../lib/supabase/server";

export const loader = createServerLoader(async (request, params) => {
  const { db } = await bs.request(request, { allow: ["user"] });
  const customer = await db.customers
    .findById(String(params.id), { select: ["id", "name"] })
    .orThrow();
  return { customer, country: request.headers.get("cf-ipcountry") };
});
```

`bs.request` throws the same `StatusError` for a refused caller. It doesn't
map a `DbException` from `.orThrow()`, so a missing row there is a 500 unless
you catch it; use `bs.loader` when you want that mapping.

To add your own step around every loader, wrap `bs.loader` in a function that
returns a `LoaderFunction`:

```ts title="src/lib/supabase/loader.ts"
import type { LoaderFunction } from "expo-server";
import { bs } from "./server";

export function timedLoader<T>(
  name: string,
  fn: Parameters<typeof bs.loader<T>>[0],
): LoaderFunction<T> {
  const loader = bs.loader(fn, { allow: ["user"] });
  return async (request, params) => {
    const started = performance.now();
    try {
      return await loader(request, params);
    } finally {
      console.info(name, Math.round(performance.now() - started), "ms");
    }
  };
}
```

For public data on routes that are rendered at build time, use
`createStaticLoader((params) => ...)` from `expo-server`. It gets no request,
so it can't run as a caller; read only data that every visitor may see.

## API routes [#api-routes]

`bs.handler(fn, options)` turns a function into an API route handler.
`Result`s become JSON or [Problem Details](/docs/concepts/results), and
`undefined` becomes 204:

```ts title="src/app/api/customers+api.ts"
import { bs } from "../../lib/supabase/server";

export const GET = bs.handler((_request, { db }) =>
  db.customers.findMany({ select: ["id", "name"] }),
);
```

`toExpo(entries, handler)` runs an `@supabase/middleware` entry array around
an API route instead. The handler gets the request, the contributions and the
route parameters, and returns a `Response`:

```ts title="src/app/api/notes+api.ts"
import { toExpo } from "better-supabase/expo";
import { withBetterSupabase } from "better-supabase/server";
import { bs } from "../../lib/supabase/server";

export const GET = toExpo([withBetterSupabase(bs)], async (_request, ctx) =>
  Response.json(await ctx.db.notes.findMany().orThrow()),
);
```

## Middleware [#middleware]

`bs.middleware()` in `+middleware.ts` refreshes an expired cookie session once
per request, before loaders run, and writes the new cookies with
`setResponseHeaders`. Loaders never refresh on their own.

```ts title="src/app/+middleware.ts"
import { bs } from "../lib/supabase/server";

export default bs.middleware({ redirectTo: "/sign-in", allow: ["user"] });
```

With `redirectTo`, a caller `allow` refuses is sent there with the original
path in `next`. Without it the middleware only refreshes the session. Bearer
tokens are never refreshed.

## Native screens [#native-screens]

Loaders and middleware run on the server for web requests. iOS and Android
use the [React Native client](/docs/frontend/react-native) and, for offline
reads, a [PowerSync database](/docs/repository/powersync). The
[Expo example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/expo-powersync)
runs one list definition on both.

# H3 and Nitro 3

> Run withBetterSupabase as H3 2 middleware, with the caller's repositories on event.context.

Source: https://bettersupabase.com/docs/frameworks/h3

`toH3(entries)` from `better-supabase/h3` returns H3 2 middleware. Every key
[`withBetterSupabase`](/docs/auth/middleware) contributes lands on
`event.context`:

```ts title="server.ts"
import { H3 } from "h3";
import { toH3 } from "better-supabase/h3";
import { createServer, withBetterSupabase } from "better-supabase/server";
import { betterSupabase } from "./lib/supabase";

const bs = createServer(betterSupabase);

const app = new H3()
  .use(toH3([withBetterSupabase(bs, { allow: ["user"] })]))
  .get("/notes", (event) => event.context.db.notes.findMany().orThrow());

export default { fetch: app.fetch };
```

The handler's value comes back to the bridge as a `Response` (a plain value as
JSON, `undefined` as 204), so the entries can add refreshed cookies and
`bs-primary-until` to it. A guard refusal answers 401 or 403 Problem Details
before the handler runs.

`guard(options)` from `better-supabase/h3` refuses callers per route, with
the options every adapter takes (`allow`, `aal`, `scopes`, `roles`,
`requireTenant`, `permission`, `authorize`, `signIn`, `mfa`). A `permission`
is decided by the guard's `authorizer` (see
[Authorizers](/docs/extending/authorizers)); without one, the route refuses
every caller. `problemOnError()` answers errors thrown by `.orThrow()` with
Problem Details:

```ts
import { H3 } from "h3";
import { guard, problemOnError } from "better-supabase/h3";

const app = new H3({ onError: problemOnError() }).get(
  "/reports",
  (event) => event.context.db.reports.findMany().orThrow(),
  { middleware: [guard({ permission: "reports:read", authorizer })] },
);
```

In Nitro 3, register the same middleware in `server/middleware`. Nuxt 3 and 4
serve through Nitro 2 on H3 1; use [`better-supabase/nuxt`](/docs/frameworks/nuxt)
there.

```ts title="server/middleware/supabase.ts"
import { defineMiddleware } from "h3";
import { toH3 } from "better-supabase/h3";
import { withBetterSupabase } from "better-supabase/server";
import { bs } from "../utils/supabase";

export default defineMiddleware(
  toH3([withBetterSupabase(bs, { refresh: true, allow: ["user", "anon"] })]),
);
```

The bridge makes the request body readable twice, so an entry and the handler
both see it.

# Hono

> Middleware, Result-aware handlers and REST resources matching your OpenAPI document.

Source: https://bettersupabase.com/docs/frameworks/hono

```ts title="src/server.ts"
import { createHono } from "better-supabase/hono";
import { betterSupabase } from "./lib/supabase";

const bs = createHono(betterSupabase);

const app = bs
  .app()
  .use("/api/*", bs.middleware())
  .get("/api/me", (c) => c.json({ kind: c.var.auth.kind }));

export default app;
```

`createHono(betterSupabase)` is [`createServer`](/docs/auth/server) plus the pieces
below, so `bs.admin()` and `bs.actingAs()` work too. `bs.app()` is
`new Hono<typeof bs.Env>()` with [`bs.onError`](#handlers) installed.
`typeof bs.Env` is the app's Hono `Env`, inferred from the definition, for
sub-apps and helpers that take a `Context`:

```ts
import type { Context } from "hono";
import { Hono } from "hono";

type Env = typeof bs.Env;
const admin = new Hono<Env>();
const tenantOf = (c: Context<Env>) =>
  c.var.auth.kind === "user" ? c.var.auth.claims.tenant_id : undefined;
```

`bs.Env` holds only a type and is `undefined` at runtime. `HonoEnv<Models,
Functions, unknown>` is the same type with the generics written out.

## Middleware [#middleware]

`bs.middleware()` resolves the caller (Bearer header first, then the session
cookie), rejects anyone not in `allow` with Problem Details, and sets:

| Variable     | Value                                                   |
| ------------ | ------------------------------------------------------- |
| `c.var.db`   | Repositories bound to the caller; RLS applies           |
| `c.var.auth` | The resolved `AuthState`                                |
| `c.var.bs`   | The full server context, including `supabase` and `sql` |

```ts
app.use("/public/*", bs.middleware({ allow: ["user", "anon"] }));
app.use("/app/*", bs.middleware({ refresh: true })); // browser routes with cookies
```

Tokens are verified locally against the cached JWKS, so the middleware makes
no network call. `refresh: true` refreshes an expiring cookie session and
adds the new cookies to the response. It is for routes browsers call with
cookies; bearer tokens never refresh.

### Entry arrays with `toHono` [#entry-arrays-with-tohono]

`bs.middleware()` runs [`withBetterSupabase`](/docs/auth/middleware) in
Hono's middleware slot. To compose it with other `@supabase/middleware`
entries (`withPostgresClient`, `withCors`, your own), pass the array to
`toHono`. Every contributed key lands on `c.var`, and Hono's response passes
back through the entries, so refreshed cookies and response headers reach it:

```ts
import { Hono } from "hono";
import { withPostgresClient } from "@supabase/server/middleware/postgres";
import { toHono } from "better-supabase/hono";
import { createServer, withBetterSupabase } from "better-supabase/server";

const bs = createServer(betterSupabase);

const app = new Hono()
  .use(
    toHono([withBetterSupabase(bs, { allow: ["user"] }), withPostgresClient()]),
  )
  .get("/api/notes", async (c) =>
    c.json(await c.var.db.notes.findMany().orThrow()),
  );
```

Chain `.use()` into the routes it gates: Hono carries the contributed keys
through the chained call's type. `c.env` seeds the pipeline, so on Workers
`getEnv` inside the entries reads the bindings.

`@supabase/server/adapters/hono` is deprecated upstream and removed on
2026-12-01. Replace `withSupabase` from it with `toHono([withBetterSupabase(bs)])`,
which contributes the same `jwtClaims`, `userClaims` and `authMode` keys.

## Typed claims [#typed-claims]

When the definition has [`.claims(schema)`](/docs/auth#typed-claims),
`c.var.auth`, `c.var.bs` and the handler context are typed by the schema's
output, and so is `profile` with `.userMetadata(schema)`. `bs.app()` and
`typeof bs.Env` carry both types; with a hand-written `HonoEnv`, pass the
claims as its fourth parameter:

```ts
const app = bs
  .app()
  .use("/api/*", bs.middleware())
  .get("/api/role", (c) =>
    c.json({
      role: c.var.auth.kind === "user" ? c.var.auth.claims.user_role : null,
    }),
  );
```

## Handlers [#handlers]

`bs.handler` unwraps what the handler returns:

* A `Result` or `AsyncResult` becomes JSON on success, or Problem Details on
  failure.
* `undefined` becomes `204`.
* A `Response` is sent as is.
* A thrown `DbException` becomes its Problem Details.

```ts
app.get(
  "/api/customers/:id",
  bs.handler((c, { db }) => db.customers.findById(c.req.param("id"))),
);
app.post(
  "/api/customers",
  bs.handler(async (c, { db }) => db.customers.create(await c.req.json()), {
    status: 201,
  }),
);
```

`bs.onError` handles errors thrown outside `bs.handler`. It sends a
`DbException` as its Problem Details, returns the response of Hono's
`HTTPException` unchanged, and sends anything else as a 500 without internal
details (unless `exposeErrors`).

Event sink sends a handler started (see
[event sinks](/docs/standards/events#sends-after-the-response)) go to
`c.executionCtx.waitUntil` on Workers. On other runtimes that stop the
invocation after the response, pass the runtime's function:

```ts
const bs = createHono(betterSupabase, {
  waitUntil: (promise) => EdgeRuntime.waitUntil(promise),
});
```

## Resources [#resources]

`bs.resource(table)` mounts REST routes that match what
[`defineApi`](/docs/specs) documents: the same paths, status codes and
bodies.

```ts
import { defineListQuery } from "better-supabase/list";

const customerList = defineListQuery(betterSupabase, "customers", {
  search: ["name"],
  facets: { status: "status" },
  sorts: { name: { name: "asc" }, newest: { createdAt: "desc" } },
  defaultSort: "newest",
});

app.route(
  "/api/customers",
  bs.resource("customers", {
    list: customerList,
    select: ["id", "name", "status"],
    input: { create: CustomerInput, update: CustomerPatch },
  }),
);
```

| Route         | Operation                                | Success                            |
| ------------- | ---------------------------------------- | ---------------------------------- |
| `GET /`       | `list`: the list query, or `page`/`size` | `200` page                         |
| `POST /`      | `create`                                 | `201` row                          |
| `GET /:id`    | `get`                                    | `200` row, `404` when RLS hides it |
| `PATCH /:id`  | `update`                                 | `200` row                          |
| `DELETE /:id` | `delete`                                 | `204`                              |

Views get `list` and `get` only. Limit operations with `operations`; other
methods answer `405` with an `Allow` header. Integer keys are parsed and
validated. `input` schemas (any Standard Schema) validate bodies before they
reach the repository.

A cross-site browser request that writes without a bearer token and without
`Content-Type: application/json` (a form post riding on the session cookie)
gets a 403 with `code: "CROSS_SITE_REQUEST"`. JSON requests from other
origins need a CORS preflight, so your CORS settings decide those. Malformed
keys and bodies answer 400 `invalid_input` with the reason in `detail`.

Pass the same options to `defineApi(betterSupabase, { resources: { customers: options } })`
and the document describes exactly these routes.

Resources also take `hooks` for business logic around each operation,
`actions` for custom routes (typed with `defineAction` from
`better-supabase/hono`), `output` when a hook changes the response shape,
and `permissions` that the server's authorizer checks:

```ts
import { createHono, defineAction } from "better-supabase/hono";

const bs = createHono(betterSupabase, { authorizer });

app.route(
  "/api/customers",
  bs.resource("customers", {
    permissions: { delete: "customers.delete" },
    actions: {
      archive: defineAction({
        method: "POST",
        path: "/{id}/archive",
        permission: "customers.archive",
        handler: (ctx, { id }) =>
          ctx.db.customers.update(String(id), { status: "archived" }),
      }),
    },
  }),
);
```

A denied permission answers 403 with `code: "PERMISSION_DENIED"`, or
`APPROVAL_REQUIRED` when the authorizer asks for an approval. See
[Resources](/docs/specs/resources) for hooks, actions and how `list`
filters rows, and [Authorizers](/docs/extending/authorizers) for the
contract.

## Testing against the local stack [#testing-against-the-local-stack]

`signLocalJwt` from `better-supabase/testing` signs a token with the local
stack's ES256 key (see [signing key](/docs/testing#signing-key)). The API
verifies it against the stack's JWKS, as it would in production, and sees
the same claims that PostgREST and RLS see:

```ts
import { signLocalJwt } from "better-supabase/testing";

const bs = createHono(betterSupabase);
const token = await signLocalJwt({ sub: userId, tenant_id: organizationId });
await app.request("/api/customers", {
  headers: { authorization: `Bearer ${token}` },
});
```

# Overview

> One adapter per framework runs withBetterSupabase in its middleware, so every route, loader and action gets the caller's repositories.

Source: https://bettersupabase.com/docs/frameworks

Each framework adapter is a subpath of `better-supabase` that runs
`withBetterSupabase` in the framework's own middleware or hook. The adapter
verifies the access token locally, builds the caller's repositories and
hands them to your routes, loaders, actions and server functions. Pick the
page for your framework; a framework without an adapter can use a bridge or
`handle()` from [Other frameworks](/docs/frameworks/other).

| Page                                                       | Covers                                                                      |
| ---------------------------------------------------------- | --------------------------------------------------------------------------- |
| [Next.js](/docs/frameworks/next)                           | The proxy, Server Components, route handlers, server actions and cache tags |
| [Cache Components](/docs/frameworks/next-cache-components) | Instant navigations, prefetching and role-aware UI with Cache Components    |
| [Hono](/docs/frameworks/hono)                              | Middleware, Result-aware handlers and REST resources                        |
| [oRPC](/docs/frameworks/orpc)                              | A middleware that adds the caller's repositories to the oRPC context        |
| [Expo Router](/docs/frameworks/expo)                       | Server loaders, API routes and middleware                                   |
| [Edge Functions](/docs/frameworks/edge)                    | Fetch handlers for Supabase Edge Functions, Deno, Bun and Workers           |
| [TanStack Start](/docs/frameworks/tanstack-start)          | Request middleware with the caller's repositories in every server function  |
| [SvelteKit](/docs/frameworks/sveltekit)                    | A `handle` hook with the caller's repositories on `event.locals`            |
| [React Router](/docs/frameworks/react-router)              | Server middleware with the caller's repositories in loaders and actions     |
| [H3 and Nitro 3](/docs/frameworks/h3)                      | H3 2 middleware with the caller's repositories on `event.context`           |
| [Elysia](/docs/frameworks/elysia)                          | The caller's repositories derived into every route                          |
| [Nuxt and Nitro 2](/docs/frameworks/nuxt)                  | The Nuxt module or `toH3V1` as Nitro 2 server middleware                    |
| [Node, Express, Fastify and Koa](/docs/frameworks/node)    | `node:http`, Express, Fastify and Koa, with route guards                    |
| [NestJS](/docs/frameworks/nestjs)                          | NestJS middleware, `@Ctx()` and role guards                                 |
| [Astro](/docs/frameworks/astro)                            | Astro middleware, page guards and Actions                                   |
| [SolidStart](/docs/frameworks/solid-start)                 | Middleware for queries, actions and API routes                              |
| [MCP servers](/docs/frameworks/mcp)                        | Tools from your tables and your own code, running as the signed-in user     |
| [Other frameworks](/docs/frameworks/other)                 | A bridge for any framework, or your own adapter with `handle()`             |

# MCP servers

> Tools from your tables and your own code, running as the signed-in user.

Source: https://bettersupabase.com/docs/frameworks/mcp

```ts title="supabase/functions/mcp/index.ts"
import { createMcp } from "better-supabase/mcp";
import { toStandardJsonSchema } from "@valibot/to-json-schema";
import * as v from "valibot";
import { customerList, betterSupabase } from "../_shared/supabase.ts";

const bs = createMcp(betterSupabase, {
  name: "crm",
  version: "1.0.0",
  instructions: "Customers and notes of the signed-in user’s organization.",
  resources: {
    customers: { list: customerList, select: ["id", "name", "status"] },
    notes: { operations: ["list", "get", "create"] },
  },
}).tool({
  name: "archive_customer",
  description: "Archive a customer and everything attached to it.",
  input: toStandardJsonSchema(v.object({ id: v.pipe(v.string(), v.uuid()) })),
  annotations: { destructiveHint: true, idempotentHint: true },
  run: ({ id }, { db }) => db.customers.update(id, { status: "archived" }),
});

Deno.serve(bs.fetch);
```

`bs.fetch` serves the MCP endpoint (Streamable HTTP, stateless JSON
responses) and the RFC 9728 metadata, at
`/.well-known/oauth-protected-resource/...` and at
`<endpoint>/oauth-protected-resource`.

The function verifies tokens itself, so turn off the gateway's JWT check.
Otherwise the gateway answers unauthenticated requests with its own 401,
without the challenge MCP clients need to sign in:

```toml title="supabase/config.toml"
[functions.mcp]
verify_jwt = false

[auth.oauth_server]
enabled = true
```

If you started from the MCP server or headless app block in the Supabase
library, [Supabase library MCP blocks](/docs/guides/supabase-blocks) shows
how to add typed repositories to its pipeline or replace its tools with
`createMcp`.

## Protocol versions [#protocol-versions]

The server follows MCP `2026-07-28` (`SPEC_PINS.mcp`). Those requests carry
their protocol version and client capabilities in `_meta`, so there is no
handshake. Clients can call `server/discover` for the supported versions,
capabilities and `instructions`. Every result has `resultType: "complete"`
and the server's name and version in `_meta`, and `tools/list` includes a
`ttlMs` and `cacheScope: "private"` caching hint. The `MCP-Protocol-Version`,
`Mcp-Method` and `Mcp-Name` headers must match the body, or the request is
rejected with a `HeaderMismatch` error.

Clients on `2025-11-25`, `2025-06-18` or `2025-03-26` open with `initialize`
as before, and `ping` still answers them. An unsupported version gets
`UnsupportedProtocolVersionError` with the list of supported versions.

Tool schemas follow the client's protocol version. From `2025-11-25` on,
`tools/list` sends JSON Schema 2020-12. Earlier revisions have no default
dialect and their clients validate draft-07, so they get draft-07 schemas:
the tool's Standard Schema library converts to draft-07 when it can, and
otherwise the 2020-12 schema is lowered (`$defs` become `definitions`).
Each tool is converted once per dialect.

## Running as the caller [#running-as-the-caller]

MCP clients send a Supabase access token as a Bearer header. It is verified
locally against the JWKS, and every tool call goes through repositories bound
to that user, so RLS decides what the model can see and change. Without a
valid token the server answers:

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://<host>/.well-known/oauth-protected-resource/mcp"
```

The metadata names Supabase Auth (`<SUPABASE_URL>/auth/v1`) as the
authorization server. Clients that support MCP authorization then sign the
user in through Supabase's OAuth server and retry. Override `resource` and
`authorizationServers` when the server sits behind a proxy.

### On Supabase Edge Functions [#on-supabase-edge-functions]

The Edge Functions gateway strips `/functions/v1` before the function sees
the request, and only routes paths under it, so a root `/.well-known` URL
never reaches the function. When `SUPABASE_FUNCTION_SLUG` or
`SB_EXECUTION_ID` is set, the server works this out the way
`withOAuthProtectedResource` from `@supabase/server` does:

* `resource` is the public origin plus `/functions/v1/<slug>`. The origin is
  `SUPABASE_PUBLIC_URL` when set, otherwise the gateway's `X-Forwarded-Host`,
  `X-Forwarded-Proto` and `X-Forwarded-Port` headers.
* The challenge points to `<resource>/oauth-protected-resource`, which the
  gateway routes to the function.
* The authorization server is Auth on the same public origin, so local
  development advertises `http://127.0.0.1:54321/auth/v1` rather than the
  internal `SUPABASE_URL`.
* `allowedHosts` is checked against `X-Forwarded-Host`, since `Host` is
  internal there. Elsewhere the server ignores that header, which a page on a
  rebinding host could set.

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://<ref>.supabase.co/functions/v1/mcp/oauth-protected-resource"
```

The same rules apply to `createMcpAuth`.

### Embedded product agents [#embedded-product-agents]

An agent inside your own product doesn't need OAuth: your backend forwards
the signed-in user's Supabase access token as `Authorization: Bearer`, and
the tools run as that user. Keep the token in the backend or the agent
orchestrator, never in a prompt or a request to the model provider. A product
session token has no `client_id`, while an OAuth token does, so decide in
`authorize` how each kind of caller is treated (see
[Bearer callers](#bearer-callers)).

### CORS [#cors]

Browser-based MCP clients send a preflight before each call. The server
answers `OPTIONS` with the allowed methods and the MCP headers
(`Authorization`, `Mcp-Protocol-Version`, `Mcp-Method`, `Mcp-Name` and the
rest), and every response exposes `WWW-Authenticate` so the client can read
the challenge. Any origin may call by default, which is safe because the
token travels in a header, not a cookie. With `allowedOrigins`, only a listed
origin is echoed back. `cors: false` turns all of it off.

A tool that starts background work enqueues it with
`{ context: db.$context }` from `run`'s `db`, and the worker runs it as the
same user with `bs.forContext(job.context)`. See
[Jobs, webhooks and agents without a session](/docs/guides/without-a-session).

A signed-in caller that `allow` or `requiredScopes` turns away gets a 403 with
the same metadata URL, so the client can ask for more access:

```http
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", error_description="...", scope="openid crm.read", resource_metadata="https://<host>/.well-known/oauth-protected-resource/mcp"
```

`advertisedScopes` lists the scopes your tools need. They appear as
`scopes_supported` in the metadata and as `scope` in both challenges.
`offline_access` is always left out: whether a client gets a refresh token is
between the client and the authorization server. `advertisedScopes` does not
refuse calls by itself. `requiredScopes` refuses a delegated token (an OAuth
client or an `act` chain) that lacks one of them, and `authorize` refuses a
single call. 0.6 removes the older `scopes` alias; `better-supabase codemod
0.5` renames it to `advertisedScopes`.

A session below `aal` gets a plain 403 without a scope challenge, since more
scopes would not help. When the server can't verify the token (the JWKS is
unreachable), the answer is a 503 Problem Details response rather than a
challenge.

## Bearer callers [#bearer-callers]

A token from the Supabase OAuth server carries the client's `client_id` and
the `scope` the user granted it. `toSession(ctx.auth)` from
`better-supabase/server` turns these into `session.actor` and
`session.delegation`, so `authorize` can check a scope per tool:

```ts title="supabase/functions/mcp/index.ts"
import { toSession } from "better-supabase/server";

const bs = createMcp(betterSupabase, {
  name: "crm",
  version: "1.0.0",
  advertisedScopes: ["openid", "crm.read", "crm.write"],
  resources: { customers: true },
  authorize: (ctx, tool) => {
    const session = toSession(ctx.auth);
    const granted =
      session.kind === "user" ? session.delegation?.scopes : undefined;
    const needed = tool.info.annotations?.readOnlyHint
      ? "crm.read"
      : "crm.write";
    return !granted || granted.includes(needed)
      ? { allowed: true }
      : { allowed: false, reason: `Needs ${needed}`, scopes: [needed] };
  },
});
```

Set the server's [token audience](/docs/auth/server#token-audience) to the
resource URL your authorization server puts in `aud`, so a token issued for
another resource is refused before any tool runs.

`delegation` is unset for the user's own token, which no scope limits. For an
agent that exchanged its token, `session.actor` is the outermost `sub` of the
RFC 8693 `act` chain and `chain` keeps the actors before it. A malformed
`act` chain is `{ kind: 'invalid', reason: 'actor' }`: the server answers
401 with the metadata challenge before any tool runs.

## Table tools [#table-tools]

Each table in `resources` gets one tool per operation:

| Tool             | Input                                          | Annotations                         |
| ---------------- | ---------------------------------------------- | ----------------------------------- |
| `<table>_list`   | the list query's JSON Schema, or `page`/`size` | `readOnlyHint`                      |
| `<table>_get`    | the key                                        | `readOnlyHint`                      |
| `<table>_create` | the `Insert` JSON Schema                       | `destructiveHint: false`            |
| `<table>_update` | the key and `patch` (the `Update` schema)      | `destructiveHint`, `idempotentHint` |
| `<table>_delete` | the key                                        | `destructiveHint`, `idempotentHint` |

With `pagination: 'cursor'` on the resource (or its list query), the list
tool takes `after` instead of `page` and returns `nextCursor`, which suits
agents that read a table one page at a time. The schemas are generated from
your database metadata, the same ones [`defineApi`](/docs/specs) publishes.
Rows come back as `structuredContent` with a matching `outputSchema`.

Table tools run through the same code as [REST resources](/docs/specs/resources),
so a resource's `permissions` and `hooks` apply to them too. Their
permissions are checked on each call, with the table and the row as the
resource; unlike a tool's `permission`, they don't hide the tool from
`tools/list`.

## Custom tools [#custom-tools]

`bs.tool(...)` is typed against your repositories. Its input can be any
Standard Schema: the tool's JSON Schema comes from Standard JSON Schema
(zod 4, ArkType, Valibot), or from `inputSchema` if you pass one. The
arguments are validated before `run`.

`run` gets the same context as the other adapters (`db`, `auth`, `supabase`,
`sql`) plus the `request` and an abort `signal`. `defineTool` builds a tool
outside the server, so it can go in `createMcp({ tools: [...] })`.

`jsonSchemaTarget` sets the dialect the tool's Standard Schema is converted
to: `draft-2020-12` (the default) or `draft-07`. Clients on protocol
versions before `2025-11-25` get draft-07 either way (see
[Protocol versions](#protocol-versions)).

## Authorizing tools [#authorizing-tools]

`requiredRoles` refuses the whole server to a signed-in user without one of
the roles, with 403 and the code `MISSING_ROLE`; service-role callers pass.
The roles are read from `app_metadata.role` unless you name another claim,
which may hold a string or an array of strings:

```ts
createMcp(betterSupabase, {
  name: "crm",
  version: "1.0.0",
  requiredRoles: { roles: ["admin", "support"], claim: "app_metadata.roles" },
});
```

RLS decides which rows a tool reads and writes. To decide whether the caller
may use a tool at all, pass `authorize` and `visible`:

```ts title="supabase/functions/mcp/index.ts"
const bs = createMcp(betterSupabase, {
  name: "crm",
  version: "1.0.0",
  advertisedScopes: ["openid", "crm.read"],
  resources: { customers: true },
  authorize: (ctx, tool, args) =>
    tool.meta === "admin" && !isAdmin(ctx.auth)
      ? { allowed: false, reason: "Only admins can archive customers" }
      : { allowed: true },
  visible: (ctx, tool) => tool.meta !== "admin" || isAdmin(ctx.auth),
}).tool({
  name: "archive_customer",
  description: "Archive a customer.",
  input: toStandardJsonSchema(v.object({ id: v.pipe(v.string(), v.uuid()) })),
  meta: "admin",
  run: ({ id }, { db }) => db.customers.update(id, { status: "archived" }),
});
```

`meta` is opaque data on a tool, for example a permission, that both hooks
receive as `tool.meta`. It is never sent to clients. Table tools take theirs
from the resource's `meta`, keyed by operation:
`resources: { customers: { meta: { delete: "admin" } } }` gives
`customers_delete` the `meta` `"admin"`, and an operation without an entry
gets `undefined`.

* `authorize(ctx, tool, args)` runs on every `tools/call`, after the
  arguments are validated and before `run`. It returns `{ allowed: true }` or
  `{ allowed: false, reason?, scopes? }`. A refusal is a tool error with
  `kind: "forbidden"` and the `reason`. A refusal with `scopes` answers 403
  with an `insufficient_scope` challenge whose `scope` lists `scopes` and
  the server's `scopes`, so the client can ask the user for more access.
* `visible(ctx, tool)` filters `tools/list`. A hidden tool is called like an
  unknown one. Lists carry `cacheScope: "private"`, so a client caches them
  per token, and the server reuses a user's list for the same `ttlMs` (five
  minutes). `tools/call` runs `visible` on every call, so hiding a tool takes
  effect at once for calls.

Both hooks can be async. An exception in either one fails the call, and the
error message is hidden unless `exposeErrors`.

### Permissions [#permissions]

To check a permission per tool, give the tool a `permission` and pass an
[authorizer](/docs/extending/authorizers) to `createMcp`:

```ts title="supabase/functions/mcp/index.ts"
const bs = createMcp(betterSupabase, {
  name: "crm",
  version: "1.0.0",
  authorizer,
}).tool({
  name: "archive_customer",
  description: "Archive a customer.",
  input: ArchiveInput,
  permission: "customers.archive",
  run: ({ id }, { db }) => db.customers.update(id, { status: "archived" }),
});
```

* `tools/call` checks the permission after the arguments are validated and
  before `run`, with `{ type: "mcp_tool", id: <tool name>, properties: <arguments> }`
  as the resource. A denial is a tool error with `kind: "forbidden"`,
  `code: "PERMISSION_DENIED"` (or `APPROVAL_REQUIRED`) and the permission
  key.
* `tools/list` hides the tools the caller is denied. Tools that share a
  permission are checked in one batch.
* Without an authorizer, a tool with a `permission` is hidden and refused,
  so a forgotten setup never opens it.

`permission` is typed from the authorizer you pass. `authorize` and
`visible` still run, after the permission check, for anything an
authorizer doesn't decide.

With [`.claims(schema)`](/docs/auth#typed-claims) on the definition,
`ctx.auth` in `authorize`, `visible` and `run` is typed by the schema's
output, so the hooks read a role without parsing the claims again:

```ts title="supabase/functions/_shared/supabase.ts"
const RoleClaims = v.looseObject({
  user_role: v.fallback(v.optional(v.string()), undefined),
});

export const betterSupabase = defineSupabase(schema).claims(RoleClaims);
```

```ts
const isAdmin = (auth: AuthState<v.InferOutput<typeof RoleClaims>>) =>
  auth.kind === "user" && auth.claims.user_role === "admin";
```

## Errors [#errors]

Failures are reported as tool results with `isError: true`, so the model can
read them and correct itself:

* A failed `Result`.
* Invalid arguments.
* A thrown `DbException`.
* Unexpected errors, whose messages are hidden unless `exposeErrors`.

The error text is the RFC 9457 Problem Details: `kind`, `detail`, and
validation `issues` with paths. Protocol problems use JSON-RPC errors: an
unknown method, an unknown tool, a malformed message, headers that disagree
with the body, or an unsupported protocol version.

Set `allowedOrigins` to reject browser requests from other origins, and
`allowedHosts` to reject requests whose `Host` header names another host
(compared without the port). Together they protect against DNS rebinding.
List every host the server answers on: production, previews and local
development.

```ts title="src/mcp.ts"
export const bs = createMcp(betterSupabase, {
  env,
  name: "crm",
  version: "1.0.0",
  allowedOrigins: ["https://claude.ai"],
  allowedHosts: ["crm.example.com", "crm.localhost", "localhost"],
  resourceDocumentation: "https://crm.example.com/docs/mcp",
});
```

`resourceDocumentation` is published as `resource_documentation` in the
protected resource metadata, so a client that hits the 401 can link the user
to the page that explains how to connect.

On runtimes that stop the invocation after the response, pass `waitUntil` to
`createMcp` (or `createMcpAuth`); it receives the
[event sink sends](/docs/standards/events#sends-after-the-response) a tool
started.

## Official MCP SDK [#official-mcp-sdk]

When the server already runs on the official SDK (`@modelcontextprotocol/server`
2.3 or later), keep its `McpServer` and add better-supabase through
`better-supabase/mcp/sdk`. `createMcpAuth` verifies the Supabase access token
locally and serves the RFC 9728 metadata; `withBetterSupabaseMcp` wraps
`registerTool` so every tool callback gets `db`, `auth` and `bs` for the
verified caller:

```ts title="src/mcp.ts"
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import { toStandardJsonSchema } from "@valibot/to-json-schema";
import { createMcpAuth, withBetterSupabaseMcp } from "better-supabase/mcp/sdk";
import * as v from "valibot";
import { betterSupabase } from "./lib/supabase/schema";

const auth = createMcpAuth(betterSupabase, {
  resource: "https://crm.example.com/mcp",
  advertisedScopes: ["crm:read"],
});

const handler = createMcpHandler(() => {
  const server = withBetterSupabaseMcp(
    new McpServer({ name: "crm", version: "1.0.0" }),
    auth,
  );
  server.registerTool(
    "archive_customer",
    {
      description: "Archive a customer.",
      inputSchema: toStandardJsonSchema(v.object({ id: v.string() })),
    },
    async ({ id }, { db }) => {
      await db.customers.update(id, { status: "archived" }).orThrow();
      return { content: [{ type: "text", text: `Archived ${id}` }] };
    },
  );
  return server;
});

export default { fetch: auth.serve(handler) };
```

The tool's `db` is bound to the verified caller, so a tool that enqueues a
job passes `{ context: db.$context }` the same way, and the job runs as that
user.

`auth.serve(handler)` answers the metadata at
`/.well-known/oauth-protected-resource/mcp`, refuses a missing or invalid
token with a 401 that points to it, and passes the verified `AuthInfo` to
`handler.fetch(request, { authInfo })`. The `allow`, `aal` and
`requiredScopes` options work as they do for `createMcp`: a delegated token
without a required scope gets a 403 `insufficient_scope` challenge, and the
user's own session is not limited by scopes.

To keep your own HTTP wiring, use the pieces instead: `auth.verifier` is an
`OAuthTokenVerifier` for the SDK's `requireBearerAuth` and
`verifyBearerToken`, `auth.metadata(request)` is the metadata response, and
`auth.contextOf(ctx)` returns the caller's context from a tool's `ctx`. A tool
call whose `authInfo` did not come from `auth.verifier` is verified again from
its token, and one without a token runs as `anon`.

To check permissions with a library that wraps the SDK's `McpServer`, wrap
the server with it first and `withBetterSupabaseMcp` second. The library then
refuses a tool before the repositories are built:

```ts
const server = withBetterSupabaseMcp(protect(new McpServer(info)), auth);
```

### Supabase's MCP server [#supabases-mcp-server]

`supabaseMcpHandler` serves Supabase's own MCP server
(`@supabase/mcp-server-supabase`) from your app, behind `createMcpAuth`. The
caller signs in with your Supabase Auth, and the server reaches the
Management API with a token your app holds, never the caller's JWT:

```bash
pnpm add @modelcontextprotocol/server @supabase/mcp-server-supabase
```

```ts title="src/ops-mcp.ts"
import { createMcpAuth, supabaseMcpHandler } from "better-supabase/mcp/sdk";
import { credentials } from "./lib/credentials";
import { betterSupabase } from "./lib/supabase/schema";

const auth = createMcpAuth(betterSupabase, {
  resource: "https://ops.example.com/mcp",
});

export default {
  fetch: supabaseMcpHandler(auth, {
    credentials,
    credentialRef: { provider: "vault", secret: "supabase-management" },
    projectRef: "abcdefghijklmnopqrst",
    features: ["database", "debugging", "docs"],
    authorize: (caller) => caller.claims.app_metadata?.role === "admin",
  }),
};
```

The handler answers the metadata and the bearer check of `auth.serve`, then
calls `authorize` with the verified caller; anyone it turns down gets a 403
before the token is read. It resolves `credentialRef` through the
[credential provider](/docs/extending/credentials) as the app, builds a
Supabase MCP server for the request and closes it when the response ends.
The server is read-only unless you pass `readOnly: false`.

Its tools administer the project, so keep `authorize` to the people who run
it. Supabase's handler speaks MCP 2026-07-28 only, so clients need a version
of the protocol that negotiates it (the official SDK client with
`versionNegotiation: { mode: "auto" }`, for example).
`@supabase/mcp-server-supabase` loads on the first request and imports Node
modules, so the handler runs on Node, Bun and Deno but not on Cloudflare
Workers.

### Which one to use [#which-one-to-use]

| You need                                                        | Use                                         |
| --------------------------------------------------------------- | ------------------------------------------- |
| Tools generated from tables and list definitions                | `createMcp`                                 |
| No MCP dependency, or the smallest bundle on an edge function   | `createMcp`                                 |
| The `authorize` and `visible` hooks                             | `createMcp`                                 |
| An existing `McpServer`, prompts, resources or SDK features     | `createMcpAuth` and `withBetterSupabaseMcp` |
| A permission library that wraps `McpServer`                     | `createMcpAuth` and `withBetterSupabaseMcp` |
| Project tools (SQL, migrations, logs) for the people who run it | `createMcpAuth` and `supabaseMcpHandler`    |

# NestJS

> Run withBetterSupabase as NestJS middleware, read the caller with @Ctx(), and guard routes by role.

Source: https://bettersupabase.com/docs/frameworks/nestjs

`better-supabase/nestjs` works on both of Nest's HTTP platforms (Express and
Fastify). Install `@nestjs/common` (version 11 or 12) in the app; the package
loads it the first time `@Ctx()` or a guard needs it.

Apply `toNestMiddleware(entries)` to the routes. A refusal from the entries (a
401, a CORS preflight) is the answer; otherwise the entries' cookies go on the
response and the contributions are kept for the request:

```ts title="src/app.module.ts"
import {
  type MiddlewareConsumer,
  Module,
  type NestModule,
} from "@nestjs/common";
import { toNestMiddleware } from "better-supabase/nestjs";
import { withBetterSupabase } from "better-supabase/server";
import { NotesController } from "./notes.controller";
import { bs } from "./supabase";

@Module({ controllers: [NotesController] })
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(toNestMiddleware([withBetterSupabase(bs, { allow: ["user"] })]))
      .forRoutes("*");
  }
}
```

`@Ctx()` injects every contribution, and `@Ctx('db')` one of them. `guard(options)`
is a guard instance for `@UseGuards`, with the options every adapter takes
(`allow`, `aal`, `scopes`, `roles`, `requireTenant`, `permission`, `authorize`,
`signIn`). A `permission` is decided by the guard's `authorizer`
(see [Authorizers](/docs/extending/authorizers)), so
`guard({ permission: "notes:export", authorizer })` refuses a caller the
authorizer denies with a 403, and refuses every caller when no authorizer is
set:

```ts title="src/notes.controller.ts"
import { Controller, Get, UseFilters, UseGuards } from "@nestjs/common";
import { Ctx, guard, problemFilter } from "better-supabase/nestjs";
import type { Db } from "./supabase";

@Controller("notes")
@UseFilters(problemFilter())
export class NotesController {
  @Get()
  list(@Ctx("db") db: Db) {
    return db.notes.findMany({ select: ["id", "title"] }).orThrow();
  }

  @Get("all")
  @UseGuards(guard({ roles: ["admin"] }))
  all(@Ctx("db") db: Db) {
    return db.notes.findMany().orThrow();
  }
}
```

A refused caller gets an `HttpException` with the Problem Details body and
status (403 for a missing role), or a 303 to `signIn` or `mfa` when you set
them. `problemFilter()` answers errors thrown by `.orThrow()` with
`application/problem+json` and other exceptions the way Nest's base filter
does; register it per controller or with `app.useGlobalFilters(problemFilter())`.

The middleware runs before the route, so entries that read the final response
(`withDbStats`, `withServerTiming`) see an empty one. `contextOf(request)`
reads the contributions outside a handler, for example in your own guards.

# Cache Components

> Instant navigations, prefetching and role-aware UI with Next.js 16.3 Cache Components.

Source: https://bettersupabase.com/docs/frameworks/next-cache-components

With `cacheComponents`, a page is a static shell that prerenders at build
time plus dynamic holes that stream in. Auth is dynamic: it reads the request
cookie. The pattern below keeps every layout synchronous, puts each session
read behind `<Suspense>`, and caches it per browser session, so:

* the first load shows the shell immediately and streams the user in;
* every later navigation is instant, because the session (and data derived
  from it) is part of the per-session App Shell that Next.js prefetches;
* the Auth server is never called during rendering. Tokens are verified
  locally against the JWKS, and only the proxy refreshes them.

The [Next.js example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/nextjs)
implements all of it: ten menu items, five of which only admins see.

```ts title="next.config.ts"
const config: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
};
```

## 1. Read the session once, privately cached [#1-read-the-session-once-privately-cached]

`bs.session()` returns the verified caller as plain data: `user`, the raw
JWT `claims` (including your custom access token hook claims) and
`expiresAt`, or `anon` / `invalid`. It never contains the token or a client,
so it can leave a `'use cache: private'` function and cross into Client
Components.

```ts title="src/features/user/user-queries.ts"
import "server-only";
import { type AuthSession, sessionStale } from "better-supabase/next";
import { cacheLife } from "next/cache";
import { bs } from "@/lib/supabase/server";

export async function getSession(): Promise<AuthSession> {
  "use cache: private";
  const session = await bs.session();
  cacheLife({ stale: sessionStale(session) });
  return session;
}
```

`sessionStale(session, { min: 30, max: 300 })` returns `max`, but never
more than the seconds left on the token, so a signed-in view is not reused
after its token expired. With fewer than `min` seconds left it returns 0.
A signed-out view that a refresh or a sign-in would change also gets 0: an
`anon` session whose cookie is `expired` or `refresh_failed` (prefetches
never refresh), and an `invalid` one (for example when the JWKS was
unreachable). Without a session cookie (`anon` with reason `none`) it
returns `max`. `stale` decides where the result can be reused:

| `stale`       | Effect                                                        |
| ------------- | ------------------------------------------------------------- |
| under 30 s    | Not prefetched. The navigation waits for the server.          |
| 30 s to 5 min | Available to per-link prefetching (`<Link prefetch={true}>`). |
| 5 min or more | Part of the route's App Shell: navigations are instant.       |

A private cache never stores anything on the server across requests. The
result only lives in that browser's router cache.

## 2. Keep layouts synchronous [#2-keep-layouts-synchronous]

A layout that awaits the session holds the whole segment, `children`
included, behind the request. Keep the layout synchronous and give each
session-dependent piece its own boundary:

```tsx title="src/app/(app)/layout.tsx"
export default function AppLayout({ children }: { children: ReactNode }) {
  return (
    <div className="app">
      <header>
        <Suspense fallback={<UserMenuSkeleton />}>
          <UserMenu />
        </Suspense>
      </header>
      <aside>
        <Suspense fallback={<SideNavSkeleton />}>
          <AppNav />
        </Suspense>
      </aside>
      <main>{children}</main>
    </div>
  );
}
```

Pages follow the same rule and export `instant = true`, so Next.js flags
anything that would block the navigation in development:

```tsx title="src/app/(app)/customers/page.tsx"
export const instant = true;

export default function CustomersPage() {
  return (
    <>
      <h1>Customers</h1>
      <Suspense fallback={<CustomerListSkeleton />}>
        <PermissionGate permission="customers.read">
          <CustomerList />
        </PermissionGate>
      </Suspense>
    </>
  );
}
```

## 3. Share the session with Client Components [#3-share-the-session-with-client-components]

`SessionProvider` takes the unresolved promise and `useSession()` unwraps it
with `use()`. Create the promise **inside** the boundary, never at the top of
a layout:

```tsx title="src/features/navigation/components/app-nav.tsx"
import { SessionProvider } from "better-supabase/react";

export function AppNav() {
  return (
    <SessionProvider sessionPromise={getSession()}>
      <SideNav />
    </SessionProvider>
  );
}
```

```tsx title="src/features/navigation/components/side-nav.tsx"
"use client";

import { useSession } from "better-supabase/react";

export function SideNav() {
  const session = useSession();
  const visible = navItems.filter(
    (item) => !item.requires || can(session, item.requires),
  );
  return <nav aria-label="Main">{/* links */}</nav>;
}
```

`SessionProvider` can be rendered straight from a Server Component: the
`react-server` build of `better-supabase/react` exports it as a client
reference. Server Components that need the session call `getSession()`
directly. Within a request that hits the same private cache entry.

## 4. Roles and permissions [#4-roles-and-permissions]

Put roles in the token, not in a query. A [custom access token
hook](https://supabase.com/docs/guides/auth/auth-hooks/custom-access-token-hook)
copies the user's role into a `user_role` claim every time Auth issues a
token, so the proxy's refresh is also what picks up role changes:

```sql title="supabase/schemas/040_rbac.sql"
create function rbac.custom_access_token_hook(event jsonb)
returns jsonb language plpgsql stable set search_path = '' as $$
declare
  claims jsonb := event -> 'claims';
  user_role rbac.app_role;
begin
  select ur.role into user_role from rbac.user_roles ur
  where ur.user_id = (event ->> 'user_id')::uuid;
  if user_role is not null then
    claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
  end if;
  return jsonb_set(event, '{claims}', claims);
end;
$$;
```

```toml title="supabase/config.toml"
[auth.hook.custom_access_token]
enabled = true
uri = "pg-functions://postgres/rbac/custom_access_token_hook"
```

Map roles to permissions in code, so the check is a pure, synchronous
function that runs in the proxy, Server Components and Client Components
alike:

```ts title="src/features/user/user-permissions.ts"
const grants = {
  admin: ["customers.read", "customers.write", "users.manage" /* … */],
  member: ["customers.read"],
} as const satisfies Record<Role, readonly Permission[]>;

export function can(session: AuthSession, permission: Permission): boolean {
  if (session.kind !== "user") return false;
  return rolesOf(session.claims).some((role) =>
    grants[role].includes(permission),
  );
}
```

Read `user_role` from the top-level claim, falling back to `app_metadata`,
and never from `user_metadata`, which users can edit. Hiding a menu item is
UX, not security: re-check in every Server Action (`can(toSession(auth), …)`)
and enforce it in the database with an `authorize(permission)` RLS helper.

### Who owns what [#who-owns-what]

| Part                                  | Owns                                                    |
| ------------------------------------- | ------------------------------------------------------- |
| Supabase Auth                         | Identity, sessions, signing and refreshing tokens       |
| Your hook (SQL)                       | Which claims a token carries, read from your tables     |
| `betterSupabase.claims(schema)`       | Validating and typing those claims on the server        |
| `betterSupabase.userMetadata(schema)` | A typed `session.profile` for display, never for access |
| RLS                                   | Enforcing access with the same claims (`auth.jwt()`)    |
| Your code or an authorization library | Mapping roles to permissions                            |

The hook, the schema and your policies read the same claims, so agree on
one shape. For multi-tenant apps, use the one the `tenant` block module
emits:

```ts
const Claims = v.looseObject({
  /** Global roles, e.g. `['support']`. */
  roles: v.optional(v.array(v.string()), []),
  /** One entry per scope the user belongs to. */
  memberships: v.optional(
    v.array(
      v.looseObject({
        scope: v.string(), // 'tenant' by default (claims.scope)
        id: v.string(),
        roles: v.array(v.string()),
      }),
    ),
    [],
  ),
  /** The active tenant; the `tenant` plugin and RLS read it. */
  tenant_id: v.optional(v.pipe(v.string(), v.uuid())),
  /** Plan features per tenant, e.g. `{ [tenantId]: ['exports'] }`. */
  features: v.optional(v.record(v.string(), v.array(v.string())), {}),
});
```

`tenant_id` is the claim the [tenant plugin](/docs/plugins/tenant) and
`current_tenant_id()` read (`claims.tenant` in the config renames it);
`memberships` lets the UI switch tenants without another query. Use
`v.looseObject` so fields another hook adds, such as an authorization
version or extra keys on a membership, survive validation. Keep the list
short: every claim is sent with every request, and
`better-supabase doctor --as <user id>` warns when the hook returns more than
2 KB, and, with an [authorization provider](/docs/extending/authorization-providers)
whose hook has a budget, when the claims it lists pass it.

> **Using an authorization library**
>
> For more than a handful of roles, an authorization library can own the roles,
> the RLS helpers and the access token hook. Plug it in as an [authorization
> provider](/docs/extending/authorization-providers), and don't add the module's
> `tenant` hook next to its hook.

## 5. Optimistic redirects in the proxy [#5-optimistic-redirects-in-the-proxy]

The proxy verifies the token locally anyway, so it can redirect before
rendering at no extra cost:

```ts title="src/proxy.ts"
const protect: NonNullable<ProxyOptions["protect"]> = (auth, request) => {
  const { pathname } = request.nextUrl;
  if (pathname === "/login" || pathname.startsWith("/api/")) return undefined;
  const session = toSession(auth);
  if (session.kind !== "user")
    return NextResponse.redirect(new URL("/login", request.url));
  const permission = requiredPermission(pathname);
  if (permission && !can(session, permission))
    return NextResponse.redirect(new URL("/", request.url));
  return undefined;
};

export const proxy = (request: NextRequest) =>
  bs.proxy(request, { protect, expiredPrefetch: "render" });
```

Prefetches never refresh, so a session whose token expired arrives as
`{ kind: "anon", reason: "expired" }`. Without `expiredPrefetch`, `protect`
sees it like any other caller and the prefetch caches a redirect to
`/login`, even though the click would refresh the session. With
`expiredPrefetch: "render"`, `protect` is skipped for those prefetches, so
they render signed out. `sessionStale` gives that view 0, so it never joins
the App Shell, and the navigation itself refreshes the session. Other
requests with an expired token, and prefetches without any session, still
go through `protect`.

## 6. Data derived from the session [#6-data-derived-from-the-session]

Per-user rows go through `bs.cached()` inside a private cache, so the
query runs with the user's token and RLS decides what comes back:

```ts title="src/features/customers/customer-queries.ts"
export async function getCustomers() {
  "use cache: private";
  const { db } = await bs.cached();
  return db.customers.findMany({ select: ["id", "name", "status"] }).orThrow();
}
```

`bs.cached()` must be called inside your own `'use cache: private'`
function: the directive has to be in app code, and Next.js keys the entry on
that function's arguments. It:

* calls `cacheLife({ stale: sessionStale(session) })`. Pass
  `{ life: { min, max, revalidate, expire } }` to change it, and
  `life.stale` to cap it: the entry then goes stale at the smaller of the two;
* tags the entry `bs:session:<user id>`, so `bs.invalidateSession(userId)`
  drops every cached view of that user, for example after an admin changes
  their role. `tags` adds more tags to the entry, and
  `bs.invalidateSession(userId, { tags })` drops those too. In a Server
  Action it also re-renders the caller's page, so no `refresh()` is needed.
  It reaches the server caches and the caller's router only: another
  user's browser keeps its private entries until they go stale or that
  user's token changes;
* with `tables`, tags the entry with each table's tag
  (`bs:<table>@<tenant>` and `bs:<table>@*` under an active tenant, else
  `bs:<table>`), so the `updateTag` after a mutation of those tables drops it
  and the next visit re-reads. `id` also tags the row of the first table:
  `bs.cached({ tables: ["customers"], id })`;
* returns the caller's context plus `session`. `db`, `supabase` and `sql` are
  built on first access;
* scopes that context to `tenant` when you pass one, for example from the
  route params. Take it as an argument of your function so it is part of the
  cache key: `getCustomers(organizationId)` calls
  `bs.cached({ tenant: organizationId })`.

### Permission snapshots [#permission-snapshots]

Cache what the UI needs to know about the caller's permissions the same way.
The snapshot needs the session, so read it first, then let `bs.cached()` set
the tags and the lifetime:

```ts title="src/lib/access.ts"
import { bs } from "@/lib/supabase";
import { permissionsFor } from "@/lib/permissions";

export async function loadSnapshot(organizationId: string) {
  "use cache: private";
  const session = await bs.session();
  const snapshot = permissionsFor(session, organizationId);
  await bs.cached({
    tags: [
      `permissions:${session.kind === "user" ? session.user.id : "anon"}`,
      `organization:${organizationId}`,
    ],
  });
  return snapshot;
}
```

Pass the promise to a Client Component provider without awaiting it, so the
layout stays synchronous and the snapshot streams in:

```tsx title="src/app/[organizationSlug]/layout.tsx"
import { PermissionsProvider } from "@/components/permissions-provider";
import { loadSnapshot } from "@/lib/access";

export default function OrganizationLayout({
  children,
  params,
}: LayoutProps<"/[organizationSlug]">) {
  const snapshotPromise = params.then(({ organizationSlug }) =>
    loadSnapshot(organizationSlug),
  );
  return (
    <PermissionsProvider snapshotPromise={snapshotPromise}>
      {children}
    </PermissionsProvider>
  );
}
```

When a role or plan changes, drop the user's cached views and their snapshot
in the Server Action or webhook that changed it:

```ts
bs.invalidateSession(userId, { tags: [`permissions:${userId}`] });
```

The user's token keeps the old memberships until it refreshes. Policies that
read the membership tables instead of the token see the change
immediately.

Outside a request, `bs.contextForSession(session, { token })` builds the same
context from a session you already have. The token is verified again, and a
token for another user gives an `invalid` context. A token that fails to
verify (expired, or the JWKS unreachable) gives the `invalid` context with
that error.

### What one scope costs [#what-one-scope-costs]

Each island that misses its private cache resolves the caller again. A
verified token is remembered until it expires (up to 256 tokens per
process), so only the first scope checks the signature, and none of them
calls the Auth server. The context builds its supabase-js client and
repositories on first use, so islands that only read `auth` build none.
Twelve islands in one render, four of which query (`pnpm --filter
better-supabase bench`, Apple M5 Max, ES256):

|        | Signature checks | Clients built | Time per render |
| ------ | ---------------- | ------------- | --------------- |
| Before | 12               | 12            | 1.42 ms         |
| After  | 1                | 4             | 0.10 ms         |

The database calls themselves are what's left; count them with the
[budget](#budget).

Data that is the same for every user belongs in a plain `'use cache'` with
`bs.cacheTag()` and `bs.admin()` (see [cache tags](/docs/frameworks/next#cache-tags)).
Don't pass a user id into a shared `'use cache'` function that uses
`bs.admin()`: that bypasses RLS.

Mutations through `bs.action()` call `updateTag`, which also clears the
client router cache, so the user sees their own write. Mutations elsewhere
(`bs.route()`, webhooks) expire the tags with `revalidateTag(tag, { expire: 0 })`.

## 7. Signing in and out [#7-signing-in-and-out]

The browser client writes the session cookie, and the router cache still
holds the old private session. Call a Server Action that runs `refresh()`
after signing in or out:

```ts title="src/features/user/user-actions.ts"
"use server";
import { refresh } from "next/cache";

export async function sessionChanged(): Promise<void> {
  refresh();
}
```

```ts
await supabase.auth.signInWithPassword({ email, password });
await sessionChanged();
router.push("/");
// `refresh()` in the Server Action expires the server cache. Prefetched
// private App Shells stay until the client refreshes too.
router.refresh();
```

`useSessionChange` from `better-supabase/next/client` runs `sessionChanged`,
then `router.push` and `router.refresh()`:

```ts
import { useSessionChange } from "better-supabase/next/client";

const go = useSessionChange(sessionChanged);
await supabase.auth.signInWithPassword({ email, password });
await go("/");
```

After a change that only the next token carries, such as switching the
active organization, pass the browser client as `refreshToken`. The hook
refreshes the session first, so the server renders with the new claims, and
throws the refresh error instead of navigating with the old token:

```ts
const switched = useSessionChange(sessionChanged, router, {
  refreshToken: supabase,
});
await switchOrganization({ organizationId });
await switched("/");
```

## Asymmetric signing keys [#asymmetric-signing-keys]

All of this depends on verifying tokens locally. With asymmetric JWT signing
keys (ES256 or RS256) the JWKS is public and cached, so the proxy, every
`bs.session()` and every `bs.context()` verify without a network call.
Hosted projects use them by default. Locally, `better-supabase doctor` warns
when `[auth] signing_keys_path` is missing; `better-supabase keys` creates
one.

## Render stages [#render-stages]

A page renders in up to four stages. Each database read belongs to the
earliest stage that can hold it; a read that lands later than it has to
turns an instant navigation into a spinner.

| Stage            | Rendered                             | Holds                                    | APIs                                                    |
| ---------------- | ------------------------------------ | ---------------------------------------- | ------------------------------------------------------- |
| 1. Static shell  | At build time, once for everyone     | Layout, headings, skeletons, public data | `bs.admin()` inside `'use cache'`                       |
| 2. App Shell     | Once per browser session, prefetched | The session and data derived from it     | `bs.cached()`, `bs.session()`, `bs.liveCount(spec, db)` |
| 3. Link prefetch | When a link enters the viewport      | The data behind that link                | `bs.cacheTags()`, read sets through `db.$many`          |
| 4. Navigation    | On click, not prefetched             | Per-request and uncached data            | `bs.context()`, `bs.route()`                            |

* **Stage 1** can't read the request. `bs.admin()` bypasses RLS, so only
  use it for data every visitor may see, and tag the entry with
  `bs.cacheTags()` so mutations revalidate it.
* **Stage 2** runs once and is reused until `sessionStale` expires or
  `bs.invalidateSession()` drops it. Keep it small: the menu, the
  session, a few counts. Pass the `db` from `bs.cached()` to
  `bs.liveCount` here; without it, `liveCount` reads the request and
  moves to stage 4.
* **Stage 3** is where most page data goes. One read set per page keeps it
  to one call, and `bs.cacheTags(readSet)` revalidates it on any mutation
  of a table it reads.
* **Stage 4** is anything that reads `searchParams`, headers or the request
  body, plus route handlers and actions. It is never prefetched, so budget
  it strictly.

## Budget [#budget]

Cache Components make it easy to spread one page over many islands, each
with its own `'use cache: private'` read. Every island that misses the cache
is a PostgREST request, and islands that wait on each other add up to
sequential round trips. Count them instead of guessing:

```ts title="src/lib/supabase/server.ts"
import "server-only";
import { createNext } from "better-supabase/next";
import { betterSupabase } from "./index";

export const bs = createNext(betterSupabase, {
  debug: { budget: { calls: 8, waves: 2 } },
});
```

```ts title="src/app/api/bs-stats/route.ts"
export const GET = bs.debugRoute();
```

* **calls** are repository operations and RPCs. **waves** are sequential
  rounds: a call that starts while another is in flight joins its wave, so
  `Promise.all` of three reads is one wave.
* The proxy gives every render a request id (`x-bs-request-id`, forwarded to
  Server Components and sent back on the document and `_rsc` responses).
  `bs.context()` records into it, across all the scopes of that render.
* In development, a render that goes over `budget` logs a warning once its
  calls have been idle for 250 ms. A private-cache hit makes no call, so
  warm navigations cost 0.
* `bs.route()` responses carry `x-bs-db-calls: calls;waves;ms` directly.
  Pages stream, so their headers leave before the render finishes; their
  totals are served by `bs.debugRoute()` at the URL in the `x-bs-stats`
  response header.
* Outside development, collecting is off and the debug route answers 404.
  Set `debug.enabled` for e2e runs against `next start`.

In code, `ctx.stats()` and `db.$stats()` return the same numbers for one
context: `{ calls, waves, tables, ms }`.

Fail CI when a page gets chattier with
[`expectDbBudget`](/docs/testing#database-budget):

```ts
await expectDbBudget(page, {
  maxCalls: 8,
  maxWaves: 2,
  during: () => page.goto("/customers"),
});
```

## Prove it with a simulated delay [#prove-it-with-a-simulated-delay]

Locally, every read is fast, so a page that waits for the database still
looks instant. Delay the server's Supabase requests and the uncached reads
show up as spinners:

```ts title="src/lib/supabase/server.ts"
import "server-only";

const delayMs = Number(process.env["BS_FETCH_DELAY_MS"] ?? "0");
const delayed: typeof fetch = async (input, init) => {
  await new Promise((resolve) => setTimeout(resolve, delayMs));
  return fetch(input, init);
};

export const bs = createNext(
  betterSupabase,
  delayMs > 0 ? { fetch: delayed, auth: { fetch: delayed } } : {},
);
```

`fetch` covers PostgREST and supabase-js, `auth.fetch` the token refreshes
and the JWKS. Under a 3 s delay, each of the three cache layers has a
signature you can assert in an e2e test:

| Layer                                       | Lives in                                | First visit | Next visit                     | Reload  |
| ------------------------------------------- | --------------------------------------- | ----------- | ------------------------------ | ------- |
| Static shell and `'use cache'`              | The server, shared by every user        | Instant     | Instant, for every user        | Instant |
| `'use cache: private'` (`bs.cached()`)      | The browser's router cache, per session | 3 s         | Instant, within its stale time | 3 s     |
| TanStack Query `staleTime` (`useQueries()`) | The browser's query cache, per tab      | 3 s         | Instant, within `staleTime`    | 3 s     |

The private cache is never stored on the server, so a reload pays for it
again; the shared cache is filled by the first request of anyone. Browser
queries go to Supabase directly, so delay those with Playwright's
`page.route()`. Time a page load to `waitUntil: "commit"`: the `load` event
waits for the whole streamed document, which hides an instant shell.

The [Next.js example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/nextjs/e2e)
runs its `latency` project against a second `next start` of the same build
with `BS_FETCH_DELAY_MS=3000`. In one run, the dashboard shell painted in
62 ms and its data in 3.4 s, Customers took 3.3 s on a full load, 40 ms on
the next visit and 3.4 s on reload, and the plan catalog rendered in 63 ms
for a second user whose own subscription took 3.4 s.

## Testing instant navigations [#testing-instant-navigations]

`@next/playwright`'s `instant()` holds back dynamic content while its
callback runs, so you can assert on exactly what the shell or prefetch
contains. Against `next start`, enable the testing API for the test build
only. Set the variable while running `next build`: a build without it ignores
the lock, and every `instant()` test passes without checking anything.

```ts title="next.config.ts"
experimental: {
  exposeTestingApiInProductionBuild: process.env.EXPOSE_TESTING_API === "1",
},
```

```ts title="e2e/customers.spec.ts"
import { instant } from "@next/playwright";
import { expect, test } from "@playwright/test";

test("customers come from the per-session App Shell", async ({ page }) => {
  await page.goto("/");
  await instant(page, async () => {
    await page.getByRole("link", { name: "Customers", exact: true }).click();
    await page.waitForURL((url) => url.pathname === "/customers");
    await expect(page.getByText("Road Runner Inc")).toBeVisible();
  });
});
```

For content that should stream after the click, assert that it is absent
inside the callback and visible after it. Run the tests against a production
build, never `next dev`, and without retries: a retry hides the regression
the test exists to catch. To check that a test can fail, remove
`'use cache: private'` from the function it depends on. The heading still
commits, but the rows never appear under the lock.

[`expectInstant`](/docs/testing#instant-navigations) from
`better-supabase/testing` wraps this pattern: it takes the navigation, the
locators that must be visible and absent under the lock, and an optional
database budget for the same navigation.

The [Next.js example's e2e suite](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/nextjs/e2e)
covers an initial load, the per-session App Shell, permission-gated pages, a
count that streams after the click, a member's view and a mutation.
`pnpm --filter @better-supabase/example-nextjs test:e2e` builds the example
with the testing API and runs it against the local stack.

# Next.js

> Proxy, Server Components, route handlers, server actions and cache tags.

Source: https://bettersupabase.com/docs/frameworks/next

```ts title="src/lib/supabase/server.ts"
import "server-only";
import { createNext } from "better-supabase/next";
import { betterSupabase } from "./index";

export const bs = createNext(betterSupabase);
```

`createNext(betterSupabase)` is [`createServer`](/docs/auth/server) plus the Next.js
pieces below, so `bs.admin()` and `bs.actingAs()` work too. It needs Next.js
16.3 or later.

## Proxy [#proxy]

```ts title="src/proxy.ts"
import { bs } from "@/lib/supabase/server";

export const proxy = (request) => bs.proxy(request);

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|webp)$).*)",
  ],
};
```

The proxy is the only place sessions are refreshed, and only for page
loads, client navigations and server actions, never for prefetches. With a
valid token it does nothing: no auth request, no cookie write. When it does
refresh, the new cookie is forwarded to the Server Components rendering the
same request, and the response gets `no-store` headers.

Protect routes with `protect`. Refreshed or cleared cookies are kept on the
response you return:

```ts
export const proxy = (request: NextRequest) =>
  bs.proxy(request, {
    protect: (auth, req) =>
      auth.kind !== "user" && req.nextUrl.pathname.startsWith("/app")
        ? NextResponse.redirect(new URL("/login", req.url))
        : undefined,
  });
```

To combine the proxy with next-intl or other middleware, use `before` and
`after` ([composition](/docs/auth/middleware#nextjs-proxy-composition)).
`serverTiming: true` adds a `Server-Timing` header with the verify and proxy
durations.

A prefetch never refreshes, so a session whose token expired reaches
`protect` as `{ kind: "anon", reason: "expired" }`. `expiredPrefetch: "render"`
skips `protect` for those prefetches, so they render signed out instead of
caching a redirect to sign-in; the click refreshes the session
([details](/docs/frameworks/next-cache-components#5-optimistic-redirects-in-the-proxy)).
The default, `"protect"`, passes them to `protect` like any other request.

### Ended sessions [#ended-sessions]

An access token stays valid until it expires, even after its session is
signed out elsewhere, revoked or deleted. RLS that checks the session then
hides every row, and the app looks empty. `endedSession` asks Auth whether
the session still exists (`GET /auth/v1/user`) on document loads and on the
paths you list. When Auth answers 401 or 403, the proxy clears the session
cookies and redirects to `redirect` with `?reason=session_ended`, or
`?reason=account_banned` when Auth reports the user as banned:

```ts
export const proxy = (request: NextRequest) =>
  bs.proxy(request, {
    endedSession: { redirect: "/login", paths: ["/"] },
    protect,
  });
```

| Option     | Default                           | Meaning                                                                    |
| ---------- | --------------------------------- | -------------------------------------------------------------------------- |
| `paths`    | none                              | Paths checked on client navigations too, such as the page after sign-in    |
| `redirect` | none                              | Where an ended session goes; without it `protect` gets a signed-out caller |
| `reasons`  | `session_ended`, `account_banned` | The `reason` query values for `{ ended, banned }` on the redirect          |

Each checked request costs one Auth request, so client navigations, server
actions and prefetches keep the local token check unless their path is in
`paths`. A request to `redirect` itself only clears the cookies. When Auth
cannot be reached or answers with a server error, the session is kept.
The resolution also carries `sessionEnded` and `sessionEndedReason`
(`"ended"` or `"banned"`) for a `protect` that renders its own page.
`endedSession: true` checks document loads without a redirect. Use
`shouldCheckSession(request, options)` to see whether a request is checked.
A proxy that does not use `bs.proxy` can run the same check with
`sessionStatus` and `clearSessionCookies`
([details](/docs/auth/sessions#ended-sessions-in-your-own-proxy)).

## Server Components [#server-components]

```tsx
export default async function Customers() {
  const { db } = await bs.context();
  const customers = await db.customers
    .findMany({ select: ["id", "name"] })
    .orThrow();
  return <CustomerList customers={customers} />;
}
```

`bs.context()` is memoized per request with React `cache()`, so layouts and
pages share one context. It verifies the token locally and builds a
stateless client; no auth call happens during rendering.

`bs.context({ tenant })` scopes the context to a tenant from the route
params, in place of the `tenant` resolver, which has no request URL here.
The tenant gets the same checks as the resolver's
([the active tenant](/docs/blocks/access#the-active-tenant)), and each
tenant gets its own memoized context. `bs.cached({ tenant })` does the same
in a private cache; pass the tenant in as an argument of your function.

`bs.require(options)` is `bs.context()` for a page only some callers may
see. It takes the same `allow`, `aal` and `scopes` as `bs.route()`, plus
`requireTenant` and `authorize` (see [server actions](#server-actions)), and
turns a refusal into the Next.js interrupt: `unauthorized()` renders the
nearest `unauthorized.tsx`, `forbidden()` the nearest `forbidden.tsx`. Both
need `experimental.authInterrupts` in `next.config.ts`; without it, a
refused caller gets `notFound()`.

```tsx title="src/app/settings/audit/page.tsx"
export default async function AuditPage() {
  const { db, tenant } = await bs.require({
    requireTenant: true,
    roles: ["admin"],
  });
  const events = await db.auditEvents
    .findMany({ where: { organizationId: tenant } })
    .orThrow();
  return <AuditLog events={events} />;
}
```

`bs.require()` reads the request, so it keeps the page out of the
prefetched App Shell. For a page under
[Cache Components](/docs/frameworks/next-cache-components), check the
privately cached session instead and call `forbidden()` yourself.

`bs.liveCount(spec)` counts on the server and returns a serializable
`{ spec, count }` seed for `useLiveCount`, so a badge renders with its
number and then stays current over Realtime. See
[live queries](/docs/frontend/live-queries#counts).

## Session [#session]

```ts
const session = await bs.session();
if (session.kind === "user") session.claims.user_role;
```

`bs.session()` is the same verification without the clients: an
`AuthSession` of `user` (with `user`, `claims` and `expiresAt`), `service`,
`anon` or `invalid`. It never includes the token, so it is safe to return
from `'use cache: private'` and to pass to Client Components through
[`SessionProvider`](/docs/frontend/react#server-session). `toSession(auth)`
converts any `AuthState` the same way, for example inside `bs.action()`.

Use `bs.session()` (or `jwtClaims` in a pipeline entry) as the per-request
check, in place of `supabase.auth.getUser()`. It verifies the token locally
against the project's JWKS and never calls the Auth server, the check
`@supabase/ssr` recommends with `getClaims()`. Call `getUser()` only in the
flows that must see a user the Auth server has revoked or deleted since the
token was issued, such as changing a password or deleting an account. The
`user` on the session comes from the claims, so it works with either cookie
[encoding](/docs/auth/sessions#encoding).

With `cacheComponents` enabled, see [Cache Components](/docs/frameworks/next-cache-components)
for instant navigations and role-aware menus.

## Route handlers [#route-handlers]

```ts title="src/app/api/customers/[id]/route.ts"
export const GET = bs.route<{ id: string }>((request, { db, params }) =>
  db.customers.findById(params.id, { select: ["id", "name"] }),
);
```

* `allow` controls who gets in; the default is `['user']`. Use
  `['user', 'anon']` for public routes or `['service']` for machine callers.
  Anonymous users (`signInAnonymously()`) get a 403 with
  `code: "ANONYMOUS_USER"` unless `allow` lists `'anonymous'`; `'anon'` means
  no session and never admits them, so a public route that serves guests too
  uses `['user', 'anonymous', 'anon']`.
* Return a `Result` or `AsyncResult` (as above, no `await` needed), a plain
  value, or a `Response`. Errors,
  thrown `DbException`s and rejected callers become
  [Problem Details](/docs/auth/problems) responses; a 401 carries a
  `WWW-Authenticate` challenge. Any other thrown error is a 500 without
  internal details (unless `exposeErrors`), while `redirect()` and
  `notFound()` still reach Next.js.

## REST resources [#rest-resources]

`bs.resources(map, options)` serves the same REST routes as
[Hono resources](/docs/frameworks/hono#resources) from one catch-all route.
It returns a handler for each method:

```ts title="src/app/api/v1/[...path]/route.ts"
import { defineAction } from "better-supabase/next";
import { bs } from "@/lib/supabase/server";

export const { GET, POST, PATCH, PUT, DELETE } = bs.resources(
  {
    customers: {
      operations: ["list", "get", "update", "delete"],
      select: ["id", "name", "status", "organizationId"],
      actions: {
        archive: defineAction({
          method: "POST",
          path: "/{id}/archive",
          summary: "Archive a customer",
          handler: (ctx, { id }) =>
            ctx.db.customers.update(
              String(id),
              { status: "archived" },
              { select: ["id", "status"] },
            ),
        }),
      },
    },
  },
  { basePath: "/api/v1" },
);
```

`basePath` is the path of the catch-all route. An unknown path answers 404
before auth resolves. The options also take the guards of `bs.route`
(`allow`, `aal`, `scopes`), and `refresh: true` refreshes an expired session
cookie (off by default, because the proxy refreshes the routes it matches).
`permissions` on a resource and `permission` on an action are decided by the
`authorizer` passed to `createNext`. See [Resources](/docs/specs/resources)
for hooks, actions and permissions, and [API documents](/docs/specs) to
serve the matching OpenAPI document.

## Bearer callers [#bearer-callers]

Route handlers serve OAuth clients, agents and other apps that send
`Authorization: Bearer <token>`. A token from the
[Supabase OAuth server](https://supabase.com/docs/guides/auth/oauth-server)
carries `client_id` and `scope`; a token exchanged for an agent carries an
RFC 8693 `act` chain. The session names who acts for the user:

| Token                                                  | `session.actor`                                | `session.delegation`                       |
| ------------------------------------------------------ | ---------------------------------------------- | ------------------------------------------ |
| The user's own                                         | not set                                        | not set                                    |
| `client_id: 'c1'`, `scope: 'openid customers:read'`    | `{ id: 'c1', kind: 'oauth-client' }`           | `{ scopes: ['openid', 'customers:read'] }` |
| `act: { sub: 'agent', act: { sub: 'mcp-42' } }`        | `{ id: 'agent', kind: 'oauth-client', chain }` | `{ scopes, chain }`                        |
| `act: { kind: 'support', sub, session_id, read_only }` | `{ id, kind: 'support', sessionId, readOnly }` | not set                                    |
| `act: { kind: 'impersonation', sub, reason }`          | `{ id, kind: 'impersonation', reason }`        | not set                                    |

The outermost `sub` is the current actor; nested levels are the actors
before it, kept on `chain` for the audit log. A support session or an
impersonated session marks its `act` with `kind` (see
[impersonation](/docs/auth/impersonation#reading-the-act-claim)). An `act`
that is not a chain of objects each with a non-empty `sub`, or has another
`kind`, resolves to `{ kind: 'invalid', reason: 'actor' }` and a 401, never
to the user alone.

`scopes` on a route limits what a delegated token (an `oauth-client` actor)
may do. The user's own session is not limited, and neither is a support or
impersonated session, which acts as the user:

```ts title="src/app/api/customers/[id]/route.ts"
export const GET = bs.route<{ id: string }>(
  (request, { db, params }) =>
    db.customers.findById(params.id, { select: ["id", "name"] }),
  { scopes: ["customers:read"] },
);
```

Bearer callers get [Problem Details](/docs/auth/problems) for every refusal:

| Case                                                     | Status | Header                                                                                          |
| -------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------- |
| No token                                                 | 401    | `WWW-Authenticate: Bearer realm="supabase"`                                                     |
| A token that does not verify, or a malformed `act` chain | 401    | `WWW-Authenticate: Bearer realm="supabase", error="invalid_token"`                              |
| A delegated token without a scope in `scopes`            | 403    | `WWW-Authenticate: Bearer realm="supabase", error="insufficient_scope", scope="customers:read"` |
| A row RLS hides from the user                            | 404    | none                                                                                            |

RLS still decides the rows: the token's `sub` is the user, so a client sees
at most what the user sees. `scopes` is accepted by `bs.action`, the edge,
Hono and oRPC guards too.

## Server actions [#server-actions]

```ts title="src/app/customers/actions.ts"
"use server";

export const createCustomer = bs.action(
  { input: customerInput }, // any Standard Schema: zod, valibot, arktype
  (input, { db }) => db.customers.create(input, { select: ["id"] }),
);
```

The action accepts a plain object or `FormData`, validates it, and returns a
serializable `ActionResult`: `{ ok: true, data }` or `{ ok: false, error }`,
where `error` is a plain `DbError` your form can render (`validation`
errors carry `issues` with paths). `tenant: (input) => input.organizationId`
scopes the action's context to a tenant from the validated input.
[`useActionForm` and `useAction`](/docs/frontend/react#server-actions) call
it from a Client Component.

`requireTenant` and `authorize` run after `allow`, `aal` and `scopes`, so
the permission check sits next to the action instead of at the top of its
body:

```ts title="src/features/organization/organization-actions.ts"
export const inviteMember = bs.action(
  {
    input: Invite,
    requireTenant: true,
    authorize: (session, input) => canAssign(session, input.role),
  },
  (input, { db, tenant }) =>
    db.invitations.create({ ...input, organizationId: tenant }),
);
```

`canAssign` stands for your own check, for example one your authorization
provider exports.

* `requireTenant: true` refuses a caller without an active tenant with a
  `forbidden` error (`code: "NO_TENANT"`) and types `ctx.tenant` as a
  `string`. The tenant is the action's `tenant`, then the server's `tenant`
  resolver, then the tenant claim.
* `permission` names a permission the `authorizer` passed to `createNext`
  must grant, checked after `roles` and before `authorize`. The resource the
  authorizer sees is `{ type: "action", properties: input }` for an action
  and `{ type: "route" }` for `bs.route()`. A denial is `forbidden` with
  `code: "PERMISSION_DENIED"` (or `APPROVAL_REQUIRED`) and the permission
  key; without an authorizer, every caller is refused (see
  [Authorizers](/docs/extending/authorizers)).
* `authorize(session, input)` gets the `AuthSession` and the validated input
  (the request, in `bs.route()`). Returning `false` refuses the caller with
  `forbidden` (`code: "NOT_AUTHORIZED"`).
* `ctx.session` is the caller's `AuthSession`, so the body doesn't call
  `toSession(ctx.auth)` again.
* `bs.route(handler, { requireTenant, authorize })` takes the same options
  and answers a refusal with a 403 Problem Details response.

Route handlers and actions pass event sink sends they started to `after()`
from `next/server`, so the function stays alive until the sends finish (see
[event sinks](/docs/standards/events#sends-after-the-response)).

With `support` in the server options, `bs.startSupport({ targetUserId, reason })`
and `bs.stopSupport()` start and end a [support session](/docs/auth/impersonation)
from an action: they set or clear its cookie, and every read after that
renders as the target. `bs.cached()` reads add a `bs:support:<id>` tag
(`supportTag(id)`), so ending the session drops the target's cached pages.

## Cache tags [#cache-tags]

Every mutation invalidates `bs:<table>`, its tenant's `bs:<table>@<tenant>`
and `bs:<table>:<id>`: with
`updateTag` inside server actions (read-your-writes), and with
`revalidateTag(tag, { expire: 0 })` elsewhere, so the next read after a
write in a route handler waits for fresh data instead of serving the stale
entry. Tag your cached reads to match:

```ts
async function publicPlans() {
  "use cache";
  bs.cacheTag("plans");
  return bs
    .admin()
    .plans.findMany({ where: { public: true } })
    .orThrow();
}
```

> **Caching user data**
>
> `"use cache"` functions cannot read cookies or headers. Cache public or
> service-level reads there. Per-user reads stay on `bs.context()`, which is
> already deduplicated per request; with Cache Components, wrap them in `"use
>   cache: private"`
> ([how](/docs/frameworks/next-cache-components#6-data-derived-from-the-session)).

### Tenant-scoped tags [#tenant-scoped-tags]

A read scoped to one tenant can say so, so a mutation in another tenant
leaves it cached:

```ts
async function customerList(tenant: string) {
  "use cache";
  const spec = betterSupabase.spec.customers.findMany({
    where: { organizationId: tenant },
  });
  bs.cacheTags(spec, { tenant });
  return bs.admin().$run(spec).orThrow();
}
```

The read is tagged `bs:customers@<tenant>` and `bs:customers@*`. A
mutation invalidates `bs:customers`, the tag of its tenant (from the
request context, such as the tenant plugin's), or `bs:customers@*` when it
has none, and the tags of the rows it changed. Reads without a `tenant` keep
`bs:<table>`, which every mutation of the table invalidates, and so does a
read across tenants (`{ tenant: "*" }`), which carries `bs:<table>` as well
as `bs:<table>@*`. `bs.cacheTag`
takes the same option as its third argument, and `tagFor(table, undefined, { tenant })` builds the tag.

Turn invalidation off with `createNext(betterSupabase, { cacheTags: false })`. The
invalidation is the `nextCache()` [cache adapter](/docs/extending/interfaces#cacheadapter);
attach others with `betterSupabase.cache(adapter)`. To serve stale entries
while they revalidate after writes outside an action, turn `cacheTags` off
and attach `nextCache({ revalidate: "max" })` yourself.

A `redirect()` or `notFound()` after a write in `bs.action()` or
`bs.route()` still sets the replica pin cookie and keeps the event sends
running.

# Node, Express, Fastify and Koa

> Run withBetterSupabase on node:http, Express, Fastify or Koa, with route guards and Problem Details errors.

Source: https://bettersupabase.com/docs/frameworks/node

`better-supabase/node` bridges Node's `IncomingMessage` and `ServerResponse`
to the Web `Request` and `Response` the entries run on. It types Express,
Fastify and Koa structurally, so it adds no dependency.

## node:http [#nodehttp]

`toNodeHandler(entries, handler)` returns a request listener. The entries run
around the handler, so refreshed session cookies, `withDbStats` totals and
`Server-Timing` reach the client. The handler's value becomes the response (a
plain value as JSON):

```ts title="server.ts"
import { createServer } from "node:http";
import { toNodeHandler } from "better-supabase/node";
import {
  createServer as createBetterServer,
  withBetterSupabase,
} from "better-supabase/server";
import { betterSupabase } from "./lib/supabase";

const bs = createBetterServer(betterSupabase);

createServer(
  toNodeHandler(
    [withBetterSupabase(bs, { allow: ["user"] })],
    (request, { db }) =>
      db.notes.findMany({ select: ["id", "title"] }).orThrow(),
  ),
).listen(3000);
```

## Express [#express]

`toExpress(entries)` is middleware that runs before the routes. A refusal from
the entries (a 401, a CORS preflight) is the answer; otherwise the entries'
cookies and headers go on the response and every contribution lands on
`res.locals`. `guard(options)` refuses callers per route, and
`problemErrorHandler()` answers errors thrown by `.orThrow()` with Problem
Details:

```ts title="app.ts"
import express from "express";
import { guard, problemErrorHandler, toExpress } from "better-supabase/node";
import { withBetterSupabase } from "better-supabase/server";
import { bs } from "./lib/server";

const app = express();
app.use(
  toExpress([
    withBetterSupabase(bs, { refresh: true, allow: ["user", "anon"] }),
  ]),
);

app.get("/notes", async (req, res) => {
  res.json(await res.locals.db.notes.findMany().orThrow());
});
app.get("/admin/members", guard({ roles: ["admin"] }), async (req, res) => {
  res.json(await res.locals.db.members.findMany().orThrow());
});

app.use(problemErrorHandler());
```

Guards take the same options as every adapter: `allow`, `aal`, `scopes`,
`roles` and `roleClaim`, `requireTenant`, `permission`, `authorize`, and the
`signIn` and `mfa` redirects for browser routes.

A `permission` needs an [authorizer](/docs/extending/authorizers), passed on
the guard. It is checked after `requireTenant` and `roles` and before
`authorize`, with `{ type: "route" }` as the resource. A guard with a
`permission` and no `authorizer` refuses every caller:

```ts
app.get(
  "/reports",
  guard({ permission: "reports:read", authorizer }),
  async (req, res) => {
    res.json(await res.locals.db.reports.findMany().orThrow());
  },
);
```

`fastifyGuard` and `koaGuard` take the same two options.

Express types `res.locals` as a record of `any`. Extend its `Locals`
interface with `BetterSupabaseContributions` once, and `res.locals.db` is
typed by your schema:

```ts title="src/express.d.ts"
import type { BetterSupabaseContributions } from "better-supabase/server";
import type { Functions, Models } from "./lib/supabase/index.ts";

declare global {
  namespace Express {
    interface Locals extends BetterSupabaseContributions<
      Models,
      Functions,
      unknown
    > {}
  }
}
```

Express 5 sends a rejected promise from an `async` route to the error
handler, so `problemErrorHandler()` answers `.orThrow()` failures without a
wrapper. The [Express example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/express)
puts these together, with a smoke test against the local stack.

## Fastify [#fastify]

`toFastify(entries)` is an `onRequest` hook that puts the contributions on
`request.locals`, and `fastifyGuard(options)` is a `preHandler`:

```ts title="app.ts"
import Fastify from "fastify";
import { fastifyGuard, toFastify } from "better-supabase/node";
import { withBetterSupabase } from "better-supabase/server";
import { bs } from "./lib/server";

const app = Fastify();
app.decorateRequest("locals", null);
app.addHook(
  "onRequest",
  toFastify([withBetterSupabase(bs, { allow: ["user"] })]),
);

app.get("/notes", (request) => request.locals.db.notes.findMany().orThrow());
app.get(
  "/admin/members",
  { preHandler: fastifyGuard({ roles: ["admin"] }) },
  (request) => request.locals.db.members.findMany().orThrow(),
);
```

## Koa [#koa]

`toKoa(entries)` puts the contributions on `ctx.state`, awaits the rest of the
chain and answers thrown `DbException`s with Problem Details. `koaGuard(options)`
refuses callers on the routes it guards:

```ts title="app.ts"
import Koa from "koa";
import { koaGuard, toKoa } from "better-supabase/node";
import { withBetterSupabase } from "better-supabase/server";
import { bs } from "./lib/server";

const app = new Koa();
app.use(toKoa([withBetterSupabase(bs, { allow: ["user"] })]));
app.use(koaGuard({ roles: ["admin"] }));
app.use(async (ctx) => {
  ctx.body = await ctx.state.db.members.findMany().orThrow();
});
```

## What runs where [#what-runs-where]

Express, Fastify and Koa own the response, so their middleware runs the
entries before the route and copies the entries' headers onto the framework's
response. Entries that read the final response (`withDbStats`,
`withServerTiming`) see an empty one there; serve those routes with
`toNodeHandler` to measure them.

The bridge reads the URL from Express's `originalUrl`, so a router mounted at a
path still sees the full path. Pass `trustProxy: true` behind a proxy you
control to read the protocol and host from `x-forwarded-proto` and
`x-forwarded-host`. The middleware leaves the request body unread, so body
parsers run as usual. `toWebRequest` and `sendWebResponse` are exported for
other Node frameworks.

# Nuxt and Nitro 2

> Register withBetterSupabase as Nitro 2 server middleware with the Nuxt module or toH3V1, and guard server routes.

Source: https://bettersupabase.com/docs/frameworks/nuxt

Nuxt 3 and 4 serve through Nitro 2, which runs on H3 1. Its events wrap Node's
request and response instead of a Web `Request`, so it has its own bridge:
`toH3V1(entries)` from `better-supabase/h3/v1`. H3 2 apps use
[`better-supabase/h3`](/docs/frameworks/h3).

## The Nuxt module [#the-nuxt-module]

Write the entries once in a server module:

```ts title="server/better-supabase.ts"
import { createServer, withBetterSupabase } from "better-supabase/server";
import { betterSupabase } from "../lib/supabase";

export const bs = createServer(betterSupabase);
export default [
  withBetterSupabase(bs, { refresh: true, allow: ["user", "anon"] }),
];
```

Then add the module and point it at that file:

```ts title="nuxt.config.ts"
export default defineNuxtConfig({
  modules: ["better-supabase/nuxt"],
  betterSupabase: { server: "~~/server/better-supabase" },
});
```

The module registers the entries as server middleware, so every server route
reads `db`, `bs`, `auth` and `tenant` from `event.context`, and auto-imports
the Vue composables in components. Set `composables: false` to skip them.

## Without the module [#without-the-module]

Register the middleware yourself in Nitro 2:

```ts title="server/middleware/supabase.ts"
import { toH3V1 } from "better-supabase/h3/v1";
import entries from "../better-supabase";

export default defineEventHandler(toH3V1(entries));
```

## Guards and errors [#guards-and-errors]

`guard(options)` returns the refusal for a caller the options refuse (Problem
Details, or a redirect to `signIn` or `mfa`), or `undefined`:

```ts title="server/api/members.get.ts"
import { guard } from "better-supabase/h3/v1";

const admins = guard({ roles: ["admin"] });

export default defineEventHandler(
  async (event) =>
    (await admins(event)) ?? event.context.db.members.findMany().orThrow(),
);
```

`problemOnError()` turns an error thrown by `.orThrow()` into a Problem Details
`Response` and returns `undefined` for every other error. On Nitro's
Cloudflare presets, `event.context.cloudflare.env` seeds `getEnv`.

The middleware runs before the route handler, so entries that read the final
response (`withDbStats`, `withServerTiming`) see an empty one under H3 1.

# oRPC

> A middleware that adds the caller's repositories to the oRPC context.

Source: https://bettersupabase.com/docs/frameworks/orpc

```ts title="src/router.ts"
import { os } from "@orpc/server";
import { createOrpc, type OrpcRequestContext } from "better-supabase/orpc";
import * as v from "valibot";
import { betterSupabase } from "./lib/supabase";

export const bs = createOrpc(betterSupabase);

const base = os.$context<OrpcRequestContext>();
const authed = base.use(bs.middleware());
const open = base.use(bs.middleware({ allow: ["user", "anon"] }));

export const router = {
  customers: {
    list: authed.handler(({ context }) =>
      bs.unwrap(context.db.customers.findMany({ select: ["id", "name"] })),
    ),
    rename: authed
      .input(
        v.object({ id: v.string(), name: v.pipe(v.string(), v.minLength(2)) }),
      )
      .handler(({ input, context }) =>
        bs.unwrap(context.db.customers.update(input.id, { name: input.name })),
      ),
  },
  health: open.handler(() => ({ ok: true })),
};
```

Serve the router with `bs.fetchHandler`. It passes the request as the
initial context, answers 404 when no procedure matches, and adds the
request's cookies to the response: refreshed session cookies with
`middleware({ refresh: true })`, and `bs-primary-until` after a write when
[read replicas](/docs/guides/read-replicas) are configured.

```ts
import { RPCHandler } from "@orpc/server/fetch";

export default {
  fetch: bs.fetchHandler(new RPCHandler(router), { prefix: "/rpc" }),
};
```

Calling `handler.handle(request, { context: { request } })` yourself also
works, but then those cookies never reach the client.

On runtimes that stop the invocation after the response, pass `waitUntil` to
`createOrpc`; `fetchHandler` hands it the
[event sink sends](/docs/standards/events#sends-after-the-response) a
procedure started.

## Entry arrays with `toOrpc` [#entry-arrays-with-toorpc]

`toOrpc(entries, handler, { prefix })` runs an `@supabase/middleware` entry
array around an oRPC fetch handler. The procedures' initial context is
`{ request }` plus every contributed key, and the response passes back
through the entries, so refreshed cookies reach the client without
`bs.middleware()`:

```ts
import { RPCHandler } from "@orpc/server/fetch";
import { toOrpc } from "better-supabase/orpc";
import { createServer, withBetterSupabase } from "better-supabase/server";

const bs = createServer(betterSupabase);
// The router's procedures read context.db.
const base = os.$context<{
  request: Request;
  db: ReturnType<typeof bs.admin>;
}>();

export default {
  fetch: toOrpc([withBetterSupabase(bs)], new RPCHandler(router), {
    prefix: "/rpc",
  }),
};
```

A refused caller gets the guard's 401 Problem Details before oRPC runs. Use
`bs.unwrap` from `createOrpc` to turn a `Result` into an `ORPCError`.

## Contract-first routers [#contract-first-routers]

With a contract from `@orpc/contract`, apply the same middleware to the
implementer. The client package imports only the contract, and the server
serves it over HTTP with `OpenAPIHandler`. This example mounts it on Hono:

```ts title="src/contract.ts"
import { oc } from "@orpc/contract";
import { openapi } from "@orpc/openapi";
import * as v from "valibot";

const customer = v.object({ id: v.string(), name: v.string() });

export const contract = {
  customers: {
    get: oc
      .meta(openapi({ method: "GET", path: "/customers/{id}" }))
      .input(v.object({ id: v.pipe(v.string(), v.uuid()) }))
      .output(customer),
  },
};
```

```ts title="src/server.ts"
import { OpenAPIHandler } from "@orpc/openapi/fetch";
import { implement } from "@orpc/server";
import type { OrpcRequestContext } from "better-supabase/orpc";
import { Hono } from "hono";

import { contract } from "./contract";

const os = implement(contract)
  .$context<OrpcRequestContext>()
  .use(bs.middleware());

const router = os.router({
  customers: {
    get: os.customers.get.handler(({ context, input }) =>
      bs.unwrap(
        context.db.customers.findById(input.id, { select: ["id", "name"] }),
      ),
    ),
  },
});

const handle = bs.fetchHandler(new OpenAPIHandler(router), { prefix: "/api" });

export const app = new Hono().all("/api/*", (c) => handle(c.req.raw));
```

`OpenAPIHandler` sends the error's status code, so a missing row answers
404 and a write that RLS rejects answers 403, each with the Problem Details
in the body's `data`. The `.meta(openapi(...))` form needs no import for its
side effects; `.route(...)` works too after `import "@orpc/openapi/extensions/route"`.
The [oRPC example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/orpc-api)
has both styles.

## Context [#context]

`bs.middleware()` resolves the caller and rejects anyone not in `allow`
(default `['user']`). It adds:

* `context.db`: repositories bound to the caller, so RLS applies.
* `context.auth`: the resolved `AuthState`.
* `context.bs`: the full server context.

With [`.claims(schema)`](/docs/auth#typed-claims) on the definition,
`context.auth` and `context.bs` are typed by the schema's output:

```ts
export const role = authed.handler(({ context }) =>
  context.auth.kind === "user" ? context.auth.claims.user_role : null,
);
```

## Permissions [#permissions]

`createOrpc` takes the server's `authorizer` option. `bs.middleware()` and
`bs.authed()` take the guard options `allow`, `aal` and `scopes`, and the
checks `requireTenant`, `roles`, `permission` and `authorize`. A
`permission` is decided by that authorizer, with a resource of
`{ type: "route" }`:

```ts title="src/router.ts"
import { createOrpc } from "better-supabase/orpc";
import * as v from "valibot";
import { authorizer } from "./lib/authorizer";
import { betterSupabase } from "./lib/supabase";

export const bs = createOrpc(betterSupabase, { authorizer });

export const remove = bs
  .authed({ requireTenant: true, permission: "customers.delete" })
  .input(v.object({ id: v.string() }))
  .handler(({ input, context }) =>
    bs.unwrap(context.db.customers.delete(input.id)),
  );
```

A refused caller gets an `ORPCError` with code `FORBIDDEN` and the Problem
Details in `data`: `NO_TENANT`, `MISSING_ROLE`, `PERMISSION_DENIED`,
`APPROVAL_REQUIRED` or `NOT_AUTHORIZED`. Without an authorizer, a procedure
with a `permission` refuses every caller. See
[Authorizers](/docs/extending/authorizers) for the contract and
`defineAuthorizer`.

## Errors [#errors]

`bs.unwrap(result)` returns the data, or throws an `ORPCError` when the
`Result` failed. It accepts a `Result`, an `AsyncResult` or a promise of
either, and the procedure's output type is the data type. The middleware also
converts thrown `DbException`s.

The error code comes from the error's HTTP status. For example `not_found`
becomes `NOT_FOUND`, `conflict` becomes `CONFLICT`, `validation` becomes
`UNPROCESSABLE_CONTENT` and `stale` becomes `PRECONDITION_FAILED`. The
error's `data` is the RFC 9457 Problem Details, so clients get the same
`kind`, `code`, `constraint` and `issues` fields as the HTTP adapters send.

```ts
import { safe } from "@orpc/client";

const [error, data] = await safe(client.customers.rename({ id, name }));
if (error?.data?.kind === "conflict") showTaken(error.data.constraint);
```

# Other frameworks

> Run withBetterSupabase in any framework through a bridge, or write an adapter with handle().

Source: https://bettersupabase.com/docs/frameworks/other

better-supabase ships a bridge for each framework below. A bridge runs an
`@supabase/middleware` entry array (with
[`withBetterSupabase`](/docs/auth/middleware) first) in the framework's
middleware slot and returns the framework's response back through the
entries.

| Framework      | Import                                                   | Docs                                              |
| -------------- | -------------------------------------------------------- | ------------------------------------------------- |
| TanStack Start | `toTanStackStart` from `better-supabase/tanstack-start`  | [TanStack Start](/docs/frameworks/tanstack-start) |
| SvelteKit      | `toSvelteKit` from `better-supabase/sveltekit`           | [SvelteKit](/docs/frameworks/sveltekit)           |
| React Router   | `toReactRouter` from `better-supabase/react-router`      | [React Router](/docs/frameworks/react-router)     |
| H3 2, Nitro 3  | `toH3` from `better-supabase/h3`                         | [H3](/docs/frameworks/h3)                         |
| Nitro 2, Nuxt  | `toH3V1` from `better-supabase/h3/v1`                    | [Nuxt](/docs/frameworks/nuxt)                     |
| Elysia         | `toElysia` from `better-supabase/elysia`                 | [Elysia](/docs/frameworks/elysia)                 |
| Node, Express  | `toNodeHandler`, `toExpress` from `better-supabase/node` | [Node](/docs/frameworks/node)                     |
| Fastify, Koa   | `toFastify`, `toKoa` from `better-supabase/node`         | [Node](/docs/frameworks/node)                     |
| NestJS         | `toNestMiddleware` from `better-supabase/nestjs`         | [NestJS](/docs/frameworks/nestjs)                 |
| Astro          | `createAstro` from `better-supabase/astro`               | [Astro](/docs/frameworks/astro)                   |
| SolidStart     | `createSolidStart` from `better-supabase/solid-start`    | [SolidStart](/docs/frameworks/solid-start)        |

The bridges are typed structurally: none of them imports its framework, so
installing better-supabase adds no framework dependency.

The `@supabase/server` adapters (`adapters/hono`, `/h3`, `/elysia` and
`/nestjs`) are deprecated upstream and removed on 2026-12-01. Move to the
bridge for your framework; NestJS apps move to `better-supabase/nestjs`.

## Write a bridge [#write-a-bridge]

A framework with a middleware that gets a `Request` and a `next()` returning
a `Response` needs about ten lines. Fold the pipeline once, hand the
framework's `next` to the terminal through the context, and return the
response:

```ts title="better-supabase-acme.ts"
import { type AnyEntry, pipeline, seedContext } from "@supabase/middleware";

const NEXT = Symbol("acme.next");

export function toAcme(entries: readonly AnyEntry[]) {
  const run = pipeline(entries, (_request, ctx) => {
    const { c, next } = (
      ctx as { [NEXT]: { c: AcmeContext; next: () => Promise<Response> } }
    )[NEXT];
    Object.assign(c.locals, ctx);
    return next();
  });
  return (c: AcmeContext, next: () => Promise<Response>) =>
    run(c.request, { ...seedContext(c.env), [NEXT]: { c, next } });
}
```

## Write an adapter [#write-an-adapter]

The built-in adapters share their request handling through
`better-supabase/server`, so an adapter for another framework is a thin layer:

| Export                                            | What it does                                                                                                                                                                                                                  |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `handle(server, request, run, options)`           | resolves the caller, applies the guard (401 or 403 Problem Details), answers `run`'s value as JSON or Problem Details, turns other errors into a 500, adds the context's cookies and hands pending event sends to `waitUntil` |
| `extendServer(server, extra)`                     | the adapter's server: every `BetterServer` method plus the adapter's own, without loading the environment early                                                                                                               |
| `flushEvents(server, waitUntil)`                  | hands event sink sends still running to `waitUntil`, for adapters that build their own responses                                                                                                                              |
| `unexpectedResponse(cause, { instance, expose })` | the 500 for a thrown error, with its message only when `expose` is set                                                                                                                                                        |
| `resolveToken(server, token)`                     | verifies a bare access token (a WebSocket's, a queue message's) without cookies                                                                                                                                               |
| `ctx.cookies()`                                   | the cookies `ctx.apply(response)` would add, for frameworks that set cookies through their own API                                                                                                                            |

An adapter for a framework whose handlers take a `Request` and return a
`Response`:

```ts title="better-supabase-acme.ts"
import type { AnyFunctions, AnyModels, BetterSupabase } from "better-supabase";
import {
  type BetterServer,
  createServer,
  extendServer,
  handle,
  type MiddlewareOptions,
  type ServerContext,
  type ServerOptions,
} from "better-supabase/server";
import type { AcmeContext } from "acme";

export interface BetterAcme<
  M extends AnyModels,
  F extends AnyFunctions,
  E,
> extends BetterServer<M, F, E> {
  route(
    fn: (c: AcmeContext, ctx: ServerContext<M, F, E>) => unknown,
    options?: MiddlewareOptions,
  ): (c: AcmeContext) => Promise<Response>;
}

export function createAcme<M extends AnyModels, D, F extends AnyFunctions, E>(
  betterSupabase: BetterSupabase<M, D, F, E>,
  options: ServerOptions & { readonly exposeErrors?: boolean } = {},
): BetterAcme<M, F, E> {
  const bs = createServer(betterSupabase, options);
  return extendServer<BetterAcme<M, F, E>>(bs, {
    route(fn, routeOptions = {}) {
      return (c) =>
        handle(bs, c.request, (ctx) => fn(c, ctx), {
          ...routeOptions,
          ...(options.exposeErrors === undefined
            ? {}
            : { expose: options.exposeErrors }),
          waitUntil: (promise) => c.waitUntil(promise),
        });
    },
  });
}
```

Then prove it behaves like the built-in adapters with
[`testAdapter`](/docs/extending/conformance):

```ts title="better-supabase-acme.test.ts"
import { testAdapter } from "better-supabase/testing";
import { it } from "vitest";

it("conforms", () =>
  testAdapter("acme", {
    betterSupabase,
    serve: (server, run, { allow, waitUntil }) => {
      const route = createAcme(betterSupabase, server).route(
        (_c, ctx) => run(ctx),
        { allow },
      );
      return (request) => route({ request, waitUntil });
    },
  }));
```

Without an adapter, `createServer(betterSupabase).context(request)` gives the
caller's `db`, `auth` and `supabase` in any framework, and
`defineResource(betterSupabase, table)` serves the REST resource routes from
any router through `resource.handle(request, ctx.db, id)`.

# React Router

> Run withBetterSupabase as React Router server middleware, with the caller's repositories in loaders and actions.

Source: https://bettersupabase.com/docs/frameworks/react-router

`toReactRouter(entries, key)` from `better-supabase/react-router` returns a
server middleware. It sets every key
[`withBetterSupabase`](/docs/auth/middleware) contributes under a
`createContext()` key, and the response `next()` returns passes back through
the entries, so refreshed session cookies reach the browser. Turn on
`future.v8_middleware` in `react-router.config.ts` first.

```ts title="app/lib/supabase/middleware.server.ts"
import { createContext } from "react-router";
import {
  type BetterSupabaseContributions,
  createServer,
  withBetterSupabase,
} from "better-supabase/server";
import { toReactRouter } from "better-supabase/react-router";
import { betterSupabase, type Functions, type Models } from "./index";

const bs = createServer(betterSupabase);

export const supabase =
  createContext<BetterSupabaseContributions<Models, Functions, unknown>>();

export const supabaseMiddleware = toReactRouter(
  [withBetterSupabase(bs, { refresh: true, allow: ["user", "anon"] })],
  supabase,
);
```

```ts title="app/root.tsx"
import { supabaseMiddleware } from "./lib/supabase/middleware.server";

export const middleware = [supabaseMiddleware];
```

Loaders and actions read the contributions from `context`:

```ts title="app/routes/notes.tsx"
import { supabase } from "../lib/supabase/middleware.server";

export async function loader({ context }: Route.LoaderArgs) {
  return context.get(supabase).db.notes.findMany().orThrow();
}
```

A guard refusal returns the 401 or 403 Problem Details instead of calling
`next()`. Put `allow: ["user"]` on a middleware in the routes that need a
signed-in user. The bridge makes the request body readable twice, so an entry
and an action both see it.

# SolidStart

> Run better-supabase as SolidStart middleware and read the caller in queries, actions and API routes.

Source: https://bettersupabase.com/docs/frameworks/solid-start

`createSolidStart(betterSupabase, options)` from `better-supabase/solid-start`
returns the server with SolidStart helpers. Pass `getRequestEvent` from
`solid-js/web` so the helpers find the request inside server functions:

```ts title="src/lib/server.ts"
import { createSolidStart } from "better-supabase/solid-start";
import { getRequestEvent } from "solid-js/web";
import { betterSupabase } from "./supabase";

export const bs = createSolidStart(betterSupabase, {
  getRequestEvent,
  signIn: "/sign-in",
});
```

```ts title="src/middleware.ts"
import { createMiddleware } from "@solidjs/start/middleware";
import { bs } from "./lib/server";

export default createMiddleware(bs.middleware);
```

Register the file as `middleware: "src/middleware.ts"` in `app.config.ts`. The
middleware verifies the caller, refreshes the session cookie on navigations and
form posts, and puts `db`, `bs`, `auth`, `tenant` and `session` on
`event.locals`.

## Queries [#queries]

`bs.require(options)` returns the caller and its repositories inside a
`"use server"` function, or throws the refusal: a redirect to `signIn` or
`mfa`, else Problem Details:

```ts title="src/lib/notes.ts"
import { query } from "@solidjs/router";
import { bs } from "./server";

export const getNotes = query(async () => {
  "use server";
  const { db } = await bs.require();
  return db.notes.findMany({ select: ["id", "title"] }).orThrow();
}, "notes");
```

`bs.locals()` returns the request's locals without a check.

## Actions [#actions]

`bs.action(options, fn)` returns a server function that takes `FormData` or a
plain object, validates it with any Standard Schema, and returns an
`ActionResult`:

```ts title="src/lib/notes.ts"
import { action } from "@solidjs/router";
import { bs } from "./server";
import { NoteInput } from "./schemas";

const createNote = bs.action({ input: NoteInput }, (input, { db }) =>
  db.notes.create(input),
);

export const addNote = action(async (form: FormData) => {
  "use server";
  return createNote(form);
});
```

The middleware runs before the route, so entries that read the final response
(`withDbStats`, `withServerTiming`) see an empty one. On Nitro's Cloudflare
presets the bindings seed `getEnv`; pass `platformEnv` to read the env
elsewhere.

# SvelteKit

> Run withBetterSupabase as a SvelteKit handle hook, with the caller's repositories on event.locals.

Source: https://bettersupabase.com/docs/frameworks/sveltekit

`toSvelteKit(entries)` from `better-supabase/sveltekit` returns a `handle`
hook. Every key [`withBetterSupabase`](/docs/auth/middleware) contributes
lands on `event.locals`, and the response `resolve(event)` renders passes back
through the entries, so refreshed session cookies reach the browser:

```ts title="src/hooks.server.ts"
import { createServer, withBetterSupabase } from "better-supabase/server";
import { toSvelteKit } from "better-supabase/sveltekit";
import { betterSupabase } from "$lib/supabase";

const bs = createServer(betterSupabase);

export const handle = toSvelteKit([
  withBetterSupabase(bs, { refresh: true, allow: ["user", "anon"] }),
]);
```

Loads, actions and endpoints read the caller's repositories from `locals`:

```ts title="src/routes/notes/+page.server.ts"
export const load = async ({ locals }) => ({
  notes: await locals.db.notes.findMany({ select: ["id", "title"] }).orThrow(),
});
```

Declare the keys once in `App.Locals`:

```ts title="src/app.d.ts"
import type { BetterSupabaseContributions } from "better-supabase/server";
import type { Functions, Models } from "$lib/supabase";

declare global {
  namespace App {
    interface Locals extends Partial<
      BetterSupabaseContributions<Models, Functions, unknown>
    > {}
  }
}
```

On Cloudflare, `event.platform.env` seeds the pipeline, so `getEnv` inside the
entries reads the bindings. Compose the hook with others through SvelteKit's
`sequence`. The bridge makes the request body readable twice, so an entry and
a form action both see it.

# TanStack Start

> Run withBetterSupabase as TanStack Start request middleware, with the caller's repositories in every server function.

Source: https://bettersupabase.com/docs/frameworks/tanstack-start

`toTanStackStart(entries)` from `better-supabase/tanstack-start` returns the
`.server()` callback of a TanStack Start middleware. Register it as request
middleware so every request, server functions included, resolves the caller
once and refreshed session cookies reach the response:

```ts title="src/start.ts"
import { createMiddleware, createStart } from "@tanstack/react-start";
import { createServer, withBetterSupabase } from "better-supabase/server";
import { toTanStackStart } from "better-supabase/tanstack-start";
import { betterSupabase } from "./lib/supabase";

const bs = createServer(betterSupabase);

export const supabase = createMiddleware().server(
  toTanStackStart([
    withBetterSupabase(bs, { refresh: true, allow: ["user", "anon"] }),
  ]),
);

export const startInstance = createStart(() => ({
  requestMiddleware: [supabase],
}));
```

Every key [`withBetterSupabase`](/docs/auth/middleware) contributes lands on
`context`. A server function that uses the middleware reads it there:

```ts title="src/server/notes.ts"
import { createServerFn } from "@tanstack/react-start";
import { supabase } from "../start";

export const listNotes = createServerFn()
  .middleware([supabase])
  .handler(({ context }) => context.db.notes.findMany().orThrow());
```

## Refresh and short circuits [#refresh-and-short-circuits]

`refresh: true` belongs on request middleware: there the bridge returns the
response through the entries, so the refreshed cookies are added once. On
server function middleware the context still flows, but the function's
result has no `Response` to carry cookies.

A guard refusal (a 401 or 403 Problem Details) is thrown as a `Response`,
which TanStack Start sends instead of running the route. Use `allow` on a
second middleware for the routes that need a signed-in user, and keep the
request middleware open (`allow: ["user", "anon"]`).

The bridge makes the request body readable twice, so an entry that reads it
and the server function both see it.

# Browser client

> Repositories in the browser that follow the session.

Source: https://bettersupabase.com/docs/frontend/client

```ts title="src/lib/supabase/client.ts"
import { createClient } from "better-supabase/client";
import { betterSupabase } from "./index";

export const bs = createClient(betterSupabase, {
  env: {
    url: import.meta.env.VITE_SUPABASE_URL,
    publishableKey: import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY,
  },
});

const customers = await bs.db.customers
  .findMany({ select: ["id", "name"] })
  .orThrow();
```

* `bs.db` carries the current session's actor and claims, so plugins
  like [`actor`](/docs/plugins/actor) and [`tenant`](/docs/plugins/tenant)
  behave as they do on the server. It is rebuilt when the user changes.
* `bs.queries` holds [TanStack Query options](/docs/frontend/query).
* `bs.auth` is a small store (`current()`, `subscribe()`) for UI state:
  `loading`, `signed-out`, or `signed-in` with the user and decoded claims.
  The claims are for display; RLS is what enforces access.
* `bs.supabase` is the supabase-js client, for auth flows, storage and
  anything else.

## Storage [#storage]

| `storage`           | Use it for                                                                                    |
| ------------------- | --------------------------------------------------------------------------------------------- |
| `cookies` (default) | apps with a server (Next.js, SvelteKit, …): the session is shared via `@supabase/ssr` cookies |
| `local`             | SPAs without a server: the session lives in localStorage                                      |

With `cookies`, `cookies: { encode: "tokens-only" }` keeps the user object
out of the cookie, and `auth.userStorage` sets where auth-js keeps it instead
(localStorage by default). Use the same `encode` as the server; see
[Encoding](/docs/auth/sessions#encoding).

With `local`, the `cookies` options are ignored and no cookie is written, so
a server (a loader, an API route or the proxy) never sees the session. Send
the access token as an `Authorization: Bearer` header to call your own API;
the server adapters read the bearer token before the cookie. Pick one mode
per app: switching moves the session, so users sign in again once.

For React Native and Expo, use
[`better-supabase/client/native`](/docs/frontend/react-native) with your own
supabase-js client. It has the same `bs.db`, `bs.queries` and `bs.auth`, never
imports `@supabase/ssr`, stores the session in the device keychain with
`secureStorage`, and refreshes it only in the foreground with
`autoRefreshOnForeground`.

The publishable key and URL are validated like on the server: a secret key
or a legacy JWT key is rejected before any request is made.

## Edge Functions [#edge-functions]

`functions<Contracts>(bs.supabase)` calls Edge Functions defined with
`defineFunction`, typed from their own code, and returns a `Result`:

```ts
import { functions } from "better-supabase/client";
import type { CreateCustomer } from "../supabase/functions/create-customer/handler.ts";

const fns = functions<{ "create-customer": CreateCustomer }>(bs.supabase);
const customer = await fns
  .invoke("create-customer", { name: "Acme", organizationId })
  .orThrow();
```

See [Edge Functions](/docs/platform/edge-functions) for the function side and
the errors `invoke` returns.

# Live queries

> Keep any query fresh over Realtime, without sending row data over the socket.

Source: https://bettersupabase.com/docs/frontend/live-queries

A live query refetches when a table it reads changes. The database sends
only "`customers` changed"; the client refetches through PostgREST, so RLS
decides what each user sees, and includes and filters stay correct.

## Setup [#setup]

1. List the tables in the config and regenerate:

   ```ts title="better-supabase.config.ts"
   export default defineConfig({
     realtime: { tables: ["customers", "notes"] },
   });
   ```

2. Add the SQL module. It installs a statement-level trigger per table
   (one broadcast per statement, not per row) and a `realtime.messages`
   policy for the topics:

   ```bash
   better-supabase gen
   better-supabase sql add realtime-tables
   ```

With the [tenant plugin](/docs/plugins/tenant), tables broadcast on a
per-tenant topic (`bs:t:public.customers:<organization>`), and the policy only lets a
user join the tenant in their `tenant_id` claim. A table without the tenant
column fails to install, so a tenant table never broadcasts to everyone by
mistake. List tables that are global on purpose in `realtime.global`; they
use one topic that every signed-in user receives:

```ts title="better-supabase.config.ts"
realtime: { tables: ["customers", "plans"], global: ["plans"] },
```

A table whose rows each belong to one user, such as notifications, can
broadcast per user instead: map it to its user column in `realtime.users`.
It uses the topic `bs:t:public.notifications:u:<user id>`, which only that
user receives, so a change to one user's rows doesn't make the rest of the
tenant refetch. The React hooks pass the signed-in user; `liveQuery` and
`liveCount` take it as `user`.

```ts title="better-supabase.config.ts"
realtime: { tables: ["notifications"], users: { notifications: "user_id" } },
```

## React [#react]

```tsx
const spec = betterSupabase.spec.customers.findMany({
  include: { notes: true },
  limit: 50,
});

function Customers() {
  const { data } = useQuery(bs.queries.$spec(spec));
  useLiveQuery(spec); // refetches on changes to customers or notes
  return <List rows={data} />;
}
```

`useLiveQuery` needs `<BetterSupabaseProvider queryClient={queryClient}>`.
The tenant defaults to the `tenant_id` claim (`claims.tenant` in the config); pass `{ tenant }` to override it.
Changes are debounced (100 ms by default, `debounceMs`), so a bulk update
causes one refetch. It returns the channel status.

## Counts [#counts]

Badges (unread messages, open tasks) only need a number. `useLiveCount`
runs a `count` spec, which is a HEAD request with no rows, and reruns only
that after each debounced change. It needs no `QueryClient`:

```tsx
const unread = betterSupabase.spec.notifications.count({
  where: { readAt: null },
});

function UnreadBadge() {
  const { count, status, error } = useLiveCount(unread);
  return <Link href="/inbox">Inbox {count ?? "–"}</Link>;
}
```

To render the number on first paint, count on the server and pass the
seed. The client then skips its first request:

```tsx
// Server Component, inside <Suspense>
const seed = await bs.liveCount(unread); // { spec, count }
return <UnreadSummary seed={seed} />;

// Client Component
const { count } = useLiveCount(seed);
```

`bs.liveCount` uses `bs.context()`; inside `'use cache: private'`,
pass the `db` from `bs.cached()` as the second argument. For a tenant from
the route, pass the `db` from `bs.context({ tenant })` or
`bs.cached({ tenant })`, and give the client hook the same
`{ tenant }`. A failed count
gives `count: null`, and the client fetches it instead. Don't seed from a
cache entry that can outlive changes made by other users: changes made
before the client joins the channel aren't broadcast to it.

A seed costs a database call in the render that makes it. For a badge in
the layout, which is part of every page, skip the seed and let the
browser count. For in-app notifications, the
[notifications block](/docs/blocks/notifications) has its own tables,
counts and private topic; the [Next.js example](/docs/examples) builds its
header badge on it with `useNotifications`.

## Without React [#without-react]

```ts
import { liveQuery } from 'better-supabase/realtime';

const live = liveQuery(betterSupabase, supabase, spec, {
  tenant: organizationId,
  onChange: (tables) => queryClient.invalidateQueries(...),
});
await live.ready;
live.unwatched; // tables the query reads that don't broadcast
await live.unsubscribe();
```

Pass a list of table keys instead of a spec to watch tables directly.
Channels are shared: ten live queries on `customers` open one channel.

`liveCount` is the same for a count spec, with the result:

```ts
import { liveCount } from "better-supabase/realtime";

using live = liveCount(betterSupabase, supabase, db, unread, {
  tenant: organizationId,
  onCount: (count) => render(count),
  onError: (error) => report(error), // the previous count stays valid
  immediate: true, // pass false when you already have a count
});
```

Out-of-order responses are dropped, so a slow request can't overwrite a
newer count.

## Reconnects [#reconnects]

Broadcast is best effort: changes sent while a client is disconnected are
lost. When a channel rejoins after `CHANNEL_ERROR`, `TIMED_OUT` or
`CLOSED`, every live query and live count on it refetches once, as if its
tables had changed.

## Doctor [#doctor]

* **BS305** flags a table in `realtime.tables` without the trigger.
* **BS306** flags tables in the `supabase_realtime` publication whose replica
  identity can't identify deleted rows.

## Limits [#limits]

A change to a table outside `realtime.tables` doesn't refresh the query;
`unwatched` lists those tables. A refetch costs one request per changed
query, which suits dashboards and lists better than high-frequency streams.
For cursors, presence or chat, use [Realtime topics](/docs/platform/realtime).

# TanStack Query

> Typed query and mutation options for every table.

Source: https://bettersupabase.com/docs/frontend/query

`createQueries(betterSupabase, db)` gives every table option factories for TanStack
Query. The payload type of your `select`/`include` flows into `useQuery`,
`useSuspenseQuery` and `queryClient.getQueryData`.

```ts
const q = bs.queries; // or createQueries(betterSupabase, () => db)

useQuery(
  q.customers.findMany({ select: ["id", "name"], where: { status: "active" } }),
);
useSuspenseQuery(q.customers.findById(id, { include: { notes: true } }));
useQuery(q.customers.paginate({ page, size: 20, count: "exact" }));
useInfiniteQuery(
  q.customers.infinite({ size: 50, orderBy: { createdAt: "desc" } }),
); // cursor
useInfiniteQuery(q.customers.infinitePages({ size: 50 })); // offset
```

The options are built on `@tanstack/query-core`, so they work with the
React, Vue, Solid, Svelte and Angular adapters alike.

Pass `skipToken` to disable a query until its input exists:
`q.customers.findById(id ?? skipToken)`.

## Infinite lists [#infinite-lists]

`infinite` pages by [cursor](/docs/repository/pagination#cursors): each page
asks for the rows after the last one, so it skips the count and a deep page
costs the same as the first. The infinite query hook in the Supabase library
blocks pages with `.range()` and `count: 'exact'` instead, which counts the
whole table on every page and shifts rows when someone inserts while the user
scrolls. Keep `infinitePages` for lists that show a total.

Load the next page when a sentinel row scrolls into view:

```tsx title="src/features/customers/customer-feed.tsx"
"use client";

import { useInfiniteQuery } from "@tanstack/react-query";
import { useEffect, useRef } from "react";

export function CustomerFeed() {
  const feed = useInfiniteQuery(
    q.customers.infinite({ size: 50, orderBy: { createdAt: "desc" } }),
  );
  const sentinel = useRef<HTMLLIElement>(null);
  const { hasNextPage, isFetchingNextPage, fetchNextPage } = feed;

  useEffect(() => {
    const node = sentinel.current;
    if (!node || !hasNextPage) return;
    const observer = new IntersectionObserver(([entry]) => {
      if (entry?.isIntersecting && !isFetchingNextPage) void fetchNextPage();
    });
    observer.observe(node);
    return () => observer.disconnect();
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);

  return (
    <ul>
      {feed.data?.pages.flatMap((page) =>
        page.items.map((customer) => (
          <li key={customer.id}>{customer.name}</li>
        )),
      )}
      <li ref={sentinel} aria-hidden />
    </ul>
  );
}
```

Pass `{ maxPages }` as the second argument to keep a window of pages in
memory on a long feed: `q.customers.infinite({ size: 50 }, { maxPages: 5 })`.
`infinitePages` also sets `getPreviousPageParam`, so a list opened at
`page: 4` can load page 3 with `fetchPreviousPage()`.

## Keys and invalidation [#keys-and-invalidation]

Keys are `['bs', table, operation, args]`. Arguments are part of the key, so
different filters are different cache entries.

Every query also carries `meta: { bsTables }`, the tables it read, includes
and relation filters too. Invalidation matches on that meta, so a write to
`notes` refetches a `customers.findMany` that included notes. Invalidate by
hand with `invalidateTables(queryClient, ['notes'])`. See
[Caching](/docs/concepts/caching). An index from table to cached queries,
kept current from the query cache's events, finds the matches, so a write to
a table no cached query read skips the cache scan.

## Clearing on sign-out [#clearing-on-sign-out]

Cached rows belong to the user who loaded them. `clearOnUserChange` removes
every `['bs', ...]` query when the user signs out or another user signs in,
and returns the function that stops listening. With React,
`<BetterSupabaseProvider queryClient={queryClient}>` calls it for you; with
Vue, Solid, Svelte or Angular Query, call it once where you create the client:

```ts
import { clearOnUserChange } from "better-supabase/query";

const stop = clearOnUserChange(queryClient, bs.auth);
```

It waits until the session has loaded before it records the first user, and
leaves queries with other keys alone.

## Mutations [#mutations]

```ts
const create = useMutation(q.customers.create({ select: ["id"] }));
const update = useMutation(q.customers.update());
update.mutate({ id, patch: { status: "active" } });
```

Mutation options invalidate every query that read a changed table on
success, including tables changed by cascading deletes. If you pass your own
`onSuccess`, it replaces that behavior; call `invalidateOnMutation(betterSupabase,
queryClient)` once instead to invalidate after every write the client makes.
It attaches the `queryCache(client)` [cache adapter](/docs/extending/interfaces#cacheadapter).

`create`, `update` and `upsert` on a table with a single-column primary key
also write the returned row into that row's `findById` entry when the write
selected every column, so a detail page opened next shows it without a fetch.

### Optimistic updates [#optimistic-updates]

`optimistic` returns `onMutate`, `onError` and `onSettled` for a mutation: it
rewrites the cached lists before the request, puts them back when it fails,
and refetches them once it settles. Spread it next to the mutation option,
which keeps its own `onSuccess` invalidation:

```tsx title="src/features/customers/rename.tsx"
import { optimistic } from "better-supabase/query";

const list = q.customers.findMany({ select: ["id", "name"] });
const rename = useMutation({
  ...q.customers.update({ select: ["id", "name"] }),
  ...optimistic.update(list),
});
rename.mutate({ id, patch: { name } });
```

`optimistic.create(list, (input) => row, { position: "start" })` adds a row
built from the input, `optimistic.remove(list)` drops the row whose id is the
mutation's variable, and `optimistic(targets, (data, variables) => next)`
rewrites any cached data. Pass an array to rewrite several lists.

## Specs and RPCs [#specs-and-rpcs]

```ts
const spec = betterSupabase.spec.customers.findMany({
  select: ["id", "name"],
  limit: 20,
});
useQuery(q.$spec(spec));

useQuery(
  q.$rpc(
    "customer_stats",
    { organizationId },
    { tables: ["customers", "notes"] },
  ),
);
const archive = useMutation(q.$rpcMutation("archive_customer"));
```

`tables` lists what the function reads, so writes to those tables refetch
it. `$rpcMutation` invalidates the tables declared with
`betterSupabase.defineRpc(name, { invalidates })`.

## Errors [#errors]

Failed queries reject with `DbException` (its `error` is the plain
`DbError`). Tell TanStack Query about it once to type `error` everywhere:

```ts
import type { DbException } from "better-supabase";

declare module "@tanstack/react-query" {
  interface Register {
    defaultError: DbException;
  }
}
```

## Server prefetching [#server-prefetching]

The same factories work on the server with the request-scoped `db`:

```tsx
const { db } = await bs.context();
const q = createQueries(betterSupabase, db);
await queryClient.prefetchQuery(
  q.customers.findMany({ select: ["id", "name"] }),
);
```

`q.$prefetch(queryClient, spec)` prefetches a spec; dehydrate and hydrate the
client as usual. Keep live pages fresh with
[`useLiveQuery`](/docs/frontend/live-queries).

# React Native

> Repositories in Expo and React Native apps, with sessions in the device keychain.

Source: https://bettersupabase.com/docs/frontend/react-native

```ts title="src/lib/supabase/native.ts"
import { createClient } from "@supabase/supabase-js";
import {
  autoRefreshOnForeground,
  createNativeClient,
  secureStorage,
} from "better-supabase/client/native";
import * as SecureStore from "expo-secure-store";
import { AppState } from "react-native";
import { betterSupabase } from "./index";

const supabase = createClient(
  process.env.EXPO_PUBLIC_SUPABASE_URL,
  process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
  {
    auth: {
      storage: secureStorage(SecureStore),
      autoRefreshToken: true,
      persistSession: true,
      detectSessionInUrl: false,
    },
  },
);

autoRefreshOnForeground(supabase, AppState);

export const bs = createNativeClient(betterSupabase, supabase);
```

`better-supabase/client/native` is the [browser client](/docs/frontend/client)
without the cookie code: it never imports `@supabase/ssr`, so Metro doesn't
bundle it into the app. `bs.db`, `bs.queries`, `bs.auth` and `bs.supabase`
work as they do in the browser, and the [React hooks](/docs/frontend/react)
take the same `bs`.

`better-supabase init expo` writes this file, a server file for
[Expo Router](/docs/frameworks/expo) and `+middleware.ts`.

## Sessions in the keychain [#sessions-in-the-keychain]

`secureStorage(SecureStore)` stores the Supabase session with
`expo-secure-store`. A session with custom claims is often larger than the
2048 bytes iOS accepts for one value, so it is split into chunks of 1800
characters (`chunkSize` changes that). Keys are rewritten to the characters
SecureStore allows. Any object with `getItemAsync`, `setItemAsync` and
`deleteItemAsync` works in its place.

Reads and writes of one key run one at a time. A write stores the new
chunks next to the old ones and switches `<key>.chunks` to them only once
all are stored, so a read never joins chunks of two sessions, and a write
that fails part way keeps the previous session.

## Refreshing in the foreground [#refreshing-in-the-foreground]

supabase-js refreshes the access token on a timer, but on React Native it
can't tell when the app goes to the background. Supabase asks apps to call
`supabase.auth.startAutoRefresh()` when the app becomes active and
`stopAutoRefresh()` when it leaves. `autoRefreshOnForeground(supabase, AppState)`
does both: it starts refreshing at once when `AppState.currentState` is
`active`, follows every `change` event after that, and returns a function
that removes the listener and stops refreshing.

```tsx title="src/app/_layout.tsx"
import { useEffect } from "react";
import { AppState } from "react-native";
import { autoRefreshOnForeground } from "better-supabase/client/native";
import { supabase } from "@/lib/supabase/native";

export default function RootLayout() {
  useEffect(() => autoRefreshOnForeground(supabase, AppState), []);
  // ...
}
```

Call it once, either at module level next to `createClient` as above or in
an effect as here, not both. `AppState` is typed by its shape, so
better-supabase never imports `react-native`; any object with `currentState`
and `addEventListener("change", listener)` works, including a fake in a unit
test.

## Large sessions [#large-sessions]

`largeSecureStorage` keeps a session of any size without chunking: it
encrypts the session with AES-GCM, stores the result in MMKV or AsyncStorage
and keeps only the 256-bit key in the keychain. Every write uses a fresh key.
When the key and the data come from different installs (a reinstall kept one
of them), the read returns `null`, so the user signs in again instead of
seeing an error.

```ts title="src/lib/supabase/native.ts"
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as SecureStore from "expo-secure-store";
import { largeSecureStorage } from "better-supabase/client/native";

const storage = largeSecureStorage({
  secureStore: SecureStore,
  storage: AsyncStorage,
});
```

It uses the global `crypto`. On Hermes builds without WebCrypto, pass
`crypto` from `react-native-quick-crypto`.

## TanStack Query on a device [#tanstack-query-on-a-device]

`syncQueryWithApp` tells TanStack Query when the app is in the foreground and
when the device is online, so queries refetch when the user returns and
pause without a connection. With `supabase`, it also calls
`autoRefreshOnForeground`, so one call covers both.

```tsx title="src/app/_layout.tsx"
import NetInfo from "@react-native-community/netinfo";
import { focusManager, onlineManager } from "@tanstack/react-query";
import { syncQueryWithApp } from "better-supabase/client/native";
import { AppState } from "react-native";

useEffect(
  () =>
    syncQueryWithApp({
      focusManager,
      onlineManager,
      appState: AppState,
      netInfo: NetInfo,
      supabase,
    }),
  [],
);
```

`persistQueryCache(storage)` is a TanStack Query persister over MMKV or
AsyncStorage. A cold start shows the cached rows before the first fetch, and
writes are throttled to one per second. The provider's `clearOnUserChange`
drops the cache when another user signs in.

```tsx
<PersistQueryClientProvider client={queryClient} persistOptions={{ persister: persistQueryCache(storage) }}>
```

## Uploading picked files [#uploading-picked-files]

React Native can't upload a `Blob`. `uploadFromUri` reads a `file://` or
`content://` URI from `expo-image-picker` or `expo-document-picker` into an
`ArrayBuffer` and uploads it to a [typed bucket](/docs/platform/storage). The
content type comes from the `contentType` option, then the file extension,
then the response header. A file that can't be read returns an
`invalid_input` error.

```ts
const picked = await ImagePicker.launchImageLibraryAsync();
const uri = picked.assets?.[0]?.uri;
if (uri) await uploadFromUri(avatars, { userId }, uri, { upsert: true });
```

## Sign-in on a device [#sign-in-on-a-device]

`better-supabase/react/native` has hooks for OAuth, deep links and protected
routes. They read the client from `<BetterSupabaseProvider>`, or take
`{ client: supabase }`.

```tsx title="src/app/(auth)/sign-in.tsx"
import * as Linking from "expo-linking";
import * as WebBrowser from "expo-web-browser";
import { useOAuth } from "better-supabase/react/native";

export default function SignIn() {
  const oauth = useOAuth({
    browser: WebBrowser,
    redirectTo: Linking.createURL("auth/callback"),
  });
  return (
    <Button
      title="GitHub"
      disabled={oauth.pending}
      onPress={() => oauth.signIn("github")}
    />
  );
}
```

`oauth.signIn` opens the provider in an auth session and finishes the
sign-in from the redirect; a dismissed browser is not an error.
`oauth.idToken` signs in with an ID token from `expo-apple-authentication` or
Google Sign-In. Add the redirect URL to the Auth redirect allow list.

`useAuthDeepLinks(Linking)` finishes sign-ins from links that open the app:
magic links, email confirmations and PKCE codes. It handles the initial URL
and every link after it, once each, and returns the last outcome.
`handleAuthDeepLink(supabase, url)` and `signInWithOAuthBrowser` in
`better-supabase/client/native` do the same without React.

```tsx title="src/app/_layout.tsx"
import { useRouter, useSegments } from "expo-router";
import {
  useAuthDeepLinks,
  useProtectedRoute,
} from "better-supabase/react/native";

function Navigation() {
  useAuthDeepLinks(Linking);
  const status = useProtectedRoute({
    segments: useSegments(),
    router: useRouter(),
  });
  if (status === "loading") return <Splash />;
  return <Stack />;
}
```

`useProtectedRoute` sends signed-out users outside the `(auth)` group to
`/sign-in`, and signed-in users inside it to `/`; `publicGroup`,
`signInHref` and `homeHref` change those. `<AuthGate fallback signedOut>`
renders by auth status for apps without expo-router. With expo-router's
`Stack.Protected`, pass `useAuth().status === "signed-in"` as its `guard`.

## Hermes and Temporal [#hermes-and-temporal]

Hermes has no `Temporal`, and installing a polyfill on `globalThis` is a side
effect most apps avoid. Pass the namespace to the definition instead:

```ts title="src/lib/supabase/index.ts"
import { defineSupabase } from "better-supabase";
import { Temporal } from "temporal-polyfill";
import { schema } from "./generated";

export const betterSupabase = defineSupabase(schema, { temporal: Temporal });
```

With `codecs: { timestamptz: "instant" }`, rows then decode `timestamptz`
columns to that namespace's `Temporal.Instant` and `timestamp` columns to its
`Temporal.PlainDateTime`; `date` columns stay strings. Every helper that reads
the time uses the namespace too. The generated Valibot and Zod schemas
check Temporal values by their tag, so they load without a global. See
[Temporal](/docs/concepts/temporal) for the types each column gets.

## Web and native in one app [#web-and-native-in-one-app]

Metro picks `file.native.ts` on iOS and Android and `file.ts` on the web. Keep
anything that only works on a device (SecureStore, PowerSync) in `.native`
files or in modules only they import, so the web bundle never loads it. The
[Expo example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/expo-powersync)
renders a list from a server loader on the web and from the PowerSync
database on the device, with one [list definition](/docs/platform/list).

## Offline reads [#offline-reads]

To read while offline, sync the tables to the device with PowerSync and run
the same repositories on its SQLite database. See
[PowerSync](/docs/repository/powersync) for the executor and
[Offline-first](/docs/guides/offline-first) for uploading local changes.

# React

> Provider, typed hooks and the server session.

Source: https://bettersupabase.com/docs/frontend/react

```tsx title="src/app/providers.tsx"
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BetterSupabaseProvider } from "better-supabase/react";
import { bs } from "@/lib/supabase/client";

const queryClient = new QueryClient();

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <BetterSupabaseProvider client={bs} queryClient={queryClient}>
        {children}
      </BetterSupabaseProvider>
    </QueryClientProvider>
  );
}
```

Create hooks typed for your schema once:

```ts title="src/lib/hooks.ts"
import { createHooks } from "better-supabase/react";
import type { bs } from "./supabase/client";

export const { useDb, useQueries, useAuth, useSupabase } =
  createHooks<typeof bs>();
```

```tsx
function CustomerList() {
  const q = useQueries();
  const { data } = useQuery(q.customers.findMany({ select: ["id", "name"] }));
  const auth = useAuth();
  if (auth.status === "signed-out") return <SignIn />;
  return (
    <ul>
      {data?.map((c) => (
        <li key={c.id}>{c.name}</li>
      ))}
    </ul>
  );
}
```

* `useAuth()` renders `loading` on the server and during hydration, then the
  real state. It uses `useSyncExternalStore`, so it never tears.
* `useDb()` and `useQueries()` re-render when the user changes.
* With `queryClient`, the provider removes all better-supabase queries when
  the user signs out or switches accounts, and refetches them when the
  token's tenant or role changes, so one user's or tenant's data never shows
  up for the next. It uses [`clearOnUserChange`](/docs/frontend/query#clearing-on-sign-out).
* `useBroadcast(topic, values, handlers, { invalidate })` subscribes to a
  [Realtime topic](/docs/platform/realtime) while mounted and refetches the
  listed tables after each message.

## Auth, search, storage and presence hooks [#auth-search-storage-and-presence-hooks]

These hooks replace the code most screens write by hand. Each reads the
provider's client, or takes `{ client: supabase }` without the provider.

```tsx title="src/features/auth/sign-in-form.tsx"
"use client";

import { useSignIn } from "better-supabase/react";

export function SignInForm() {
  const signIn = useSignIn({ onSuccess: () => router.push("/") });
  return (
    <form
      action={(form) =>
        signIn.password({
          email: String(form.get("email")),
          password: String(form.get("password")),
        })
      }
    >
      {signIn.error ? <p role="alert">{signIn.error.message}</p> : null}
      <button disabled={signIn.pending}>Sign in</button>
    </form>
  );
}
```

| Hook                                       | What it returns                                                                                              |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| `useSignIn()`                              | `password`, `otp`, `verifyOtp` and `oauth`, with `pending` and the last `error`                              |
| `useSignOut()`                             | `signOut(options)`, with `pending` and `error`; the provider then clears cached queries                      |
| `useDebouncedSearch(initial, { delayMs })` | `value` and `setValue` for the input, the settled `term`, and `pattern` with `%` and `_` escaped for `ilike` |
| `useSignedUrl(bucket, target, { ttl })`    | a signed URL that is signed again before it expires; `null` pauses                                           |
| `useUpload(bucket)`                        | `upload(target, body)`, `progress` from 0 to 1, `status`, `abort` and `reset`                                |
| `usePresence(topic, values, { state })`    | the `members` on a presence topic, `track` and `untrack`                                                     |

`useDebouncedSearch` pairs with `contains`, which escapes the term itself:
`where: search.term ? { name: { contains: search.term } } : {}`. `escapeLike`
is exported for filters you build yourself.

`useUpload` reports real progress where `XMLHttpRequest` exists (browsers
and React Native): the upload goes to a signed upload URL, and `abort`
cancels the request. Elsewhere, and for uploads with `metadata`, progress
jumps to 1 when the upload finishes. The same `onProgress` option works on
`bucket.upload()` directly.

`usePresence` tracks the `state` option after the join and again when its
JSON changes; pass `null` to untrack. It rejoins when the user changes.

## Server Actions [#server-actions]

`useAction` and `useActionForm` call a [`bs.action()`](/docs/frameworks/next#server-actions)
Server Action and track its `ActionResult`, so a component doesn't need its
own transition, pending flag and error state.

```tsx title="src/features/customers/components/create-customer-form.tsx"
"use client";

import { useActionForm } from "better-supabase/react";

import { createCustomer } from "../customer-actions";

export function CreateCustomerForm() {
  const form = useActionForm(createCustomer, {
    onSuccess: (customer) => toast.success(`${customer.name} added`),
  });
  return (
    <form {...form.formProps}>
      <Input name="name" aria-invalid={form.fieldErrors.name !== undefined} />
      {form.fieldErrors.name ? <p>{form.fieldErrors.name}</p> : null}
      <Button type="submit" disabled={form.pending}>
        Add
      </Button>
    </form>
  );
}
```

* `useActionForm(action, { onSuccess, onError, resetOnSuccess })` submits
  the form's `FormData`. The fields keep what the user typed when the action
  fails and clear on success (`resetOnSuccess: false` keeps them, for an edit
  form). `fieldErrors` holds the first message per field of a `validation`
  error; `fieldErrorsOf(error)` returns the same for any `DbError`.
* `useAction(action, { onSuccess, onError })` runs the action from an event
  handler with `run(input)`, which resolves with the `ActionResult`. The
  input type drops `FormData`, so `run` takes the action's object input.
  `pending` covers the run and the re-render it causes, `data` and `error`
  hold the last result, and `pendingInputs` lists the inputs in flight, so
  one table row can show its own change before the server confirms it:

```tsx
const roleChange = useAction(updateMemberRole);
const roleOf = (member: Member) =>
  roleChange.pendingInputs.findLast((input) => input.userId === member.userId)
    ?.role ?? member.role;
```

* A thrown error (not an `ActionResult` error) still reaches the nearest
  error boundary.

`createErrorMessages(messages)` from `better-supabase` turns a `DbError`
into the sentence to show. It takes a message, or a function of the error,
for every [error kind](/docs/repository/unique-and-errors#error-kinds); leaving a kind out is a type
error, so a new kind can't fall through to a generic message. Build it
where your translations are:

```ts title="src/lib/use-error-message.ts"
import { createErrorMessages } from "better-supabase";

export function useErrorMessage() {
  const t = useExtracted("errors");
  return createErrorMessages({
    unauthorized: t("Sign in again to continue."),
    forbidden: t("You don't have access to this."),
    raised: (error) => error.message,
    // ...every other kind
  });
}
```

## Server session [#server-session]

`useAuth()` reads the browser client, so it is `loading` during server
rendering. To render signed-in UI on the server, hand Client Components the
server-verified session instead: pass a `bs.session()` promise to
`SessionProvider` and read it with `useSession()`.

```tsx title="Server Component, inside <Suspense>"
import { SessionProvider } from "better-supabase/react";

<SessionProvider sessionPromise={getSession()}>
  <SideNav />
</SessionProvider>;
```

```tsx title="Client Component"
"use client";
import { useSession } from "better-supabase/react";

export function SideNav() {
  const session = useSession(); // suspends until the promise resolves
  return session.kind === "user" ? <Menu claims={session.claims} /> : null;
}
```

* `SessionProvider` renders from Server Components: the `react-server` build
  exports it as a client reference.
* `useSession()` suspends, so keep it under a `<Suspense>` boundary, and
  create the promise inside that boundary.
* The session is a plain `AuthSession` (no token), the same type
  `bs.session()` returns. `tenantOf(session)` reads its active tenant (the
  tenant claim at the top level of the token, then in `app_metadata`), on
  the server and in the browser.

See [Cache Components](/docs/frameworks/next-cache-components) for the full
pattern, including role-filtered menus.

## A tenant from the URL [#a-tenant-from-the-url]

When the tenant is part of the route (`/[organizationId]/customers`), the
browser client doesn't read the URL: `useDb()`, `useQueries()` and the live
hooks scope tenant tables to the `tenant_id` claim. Resolve the tenant on the
server and hand it down:

* Read the rows in a Server Component with
  `bs.context({ tenant: organizationId })`, or in a
  `'use cache: private'` function that takes `organizationId` as an argument
  and calls `bs.cached({ tenant: organizationId })`. Pass the rows to the
  Client Component.
* Pass `organizationId` to the Client Component as a prop, and send it in the
  action's input. The action scopes its context with
  `tenant: (input) => input.organizationId`, so a tenant the caller doesn't
  belong to grants nothing, the same as a tenant from the resolver.
* Pass the same id to the live hooks: `useLiveQuery(spec, { tenant })` and
  `useLiveCount(seed, { tenant })`. Seed the count with the `db` from the
  scoped context: `bs.liveCount(spec, db)`.

```tsx title="src/app/[organizationId]/customers/page.tsx"
export default async function Customers({
  params,
}: PageProps<"/[organizationId]/customers">) {
  const { organizationId } = await params;
  const customers = await getCustomers(organizationId);
  return <CustomerList organizationId={organizationId} customers={customers} />;
}
```

```tsx title="src/features/customers/customer-list.tsx"
"use client";

export function CustomerList({ organizationId, customers }: Props) {
  useLiveQuery(customersSpec, { tenant: organizationId });
  const add = (name: string) => createCustomer({ organizationId, name });
  // ...
}
```

```ts title="src/features/customers/customer-actions.ts"
"use server";

export const createCustomer = bs.action(
  { input: CreateCustomer, tenant: (input) => input.organizationId },
  (input, { db }) =>
    db.customers.create({ name: input.name }, { select: ["id"] }),
);
```

The Realtime policy still decides which tenant topics a user may join (see
[live queries](/docs/frontend/live-queries)).

# Solid

> A provider and typed primitives for Solid and SolidStart from better-supabase/solid.

Source: https://bettersupabase.com/docs/frontend/solid

`better-supabase/solid` binds the [browser client](/docs/frontend/client) to
Solid. Wrap the app in the provider, next to TanStack Solid Query:

```tsx title="src/app.tsx"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import { BetterSupabaseProvider } from "better-supabase/solid";
import { bs } from "./lib/supabase/client";

const queryClient = new QueryClient();

export default function App(props: { children: JSX.Element }) {
  return (
    <QueryClientProvider client={queryClient}>
      <BetterSupabaseProvider client={bs} queryClient={queryClient}>
        {props.children}
      </BetterSupabaseProvider>
    </QueryClientProvider>
  );
}
```

With `queryClient`, the provider removes all better-supabase queries when
the user signs out or switches accounts, and refetches them when the
token's tenant or role changes. Pass `session` (an accessor, for example
`createAsync(() => getSession())`) to share a server-verified session with
`useSession()`.

Create primitives typed for your schema once:

```ts title="src/lib/primitives.ts"
import { createBindings } from "better-supabase/solid";
import type { bs } from "./supabase/client";

export const { useDb, useQueries, useAuth, useSession } =
  createBindings<typeof bs>();
```

## Primitives [#primitives]

| Primitive                               | Returns                                                          |
| --------------------------------------- | ---------------------------------------------------------------- |
| `useAuth()`                             | an accessor for `loading`, `signed-out` or `signed-in`           |
| `useSupabase()`                         | the supabase-js client                                           |
| `useLiveQuery(spec, options)`           | an accessor for the subscription status                          |
| `useLiveCount(source, options)`         | a reactive `{ count, status, error }`                            |
| `useBroadcast(topic, values, handlers)` | an accessor for the subscription status                          |
| `usePresence(topic, values, { state })` | a reactive `{ members, status, track, untrack }`                 |
| `useAction(action, { onSuccess })`      | a reactive `{ pending, pendingInputs, data, error, run, reset }` |
| `useSession()`, `useSupportSession()`   | the provider's server-verified session                           |

Inputs are accessors, so a subscription follows the signals it reads. A
`null` spec or `null` values pause it:

```tsx title="src/components/unread-badge.tsx"
import { useLiveCount } from "better-supabase/solid";
import { betterSupabase } from "../lib/supabase";

export function UnreadBadge() {
  const unread = useLiveCount(() =>
    betterSupabase.spec.messages.count({ where: { read: false } }),
  );
  return <span>{unread.count ?? "..."}</span>;
}
```

Subscriptions start on mount, never during server rendering, and stop when
the owner is disposed. For SolidStart server routes, see
[SolidStart](/docs/frameworks/solid-start).

# Svelte

> Svelte 5 bindings for live queries, presence, actions and the session from better-supabase/svelte.

Source: https://bettersupabase.com/docs/frontend/svelte

`better-supabase/svelte` binds the [browser client](/docs/frontend/client)
to Svelte 5. Bind it once in the root layout, next to TanStack Svelte Query:

```svelte title="src/routes/+layout.svelte"
<script lang="ts">
  import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query";
  import { setBetterSupabase, setSession } from "better-supabase/svelte";
  import { bs } from "$lib/supabase/client";

  let { data, children } = $props();
  const queryClient = new QueryClient();

  setBetterSupabase(bs, { queryClient });
  setSession(() => data.session);
</script>

<QueryClientProvider client={queryClient}>
  {@render children()}
</QueryClientProvider>
```

With `queryClient`, better-supabase queries are removed when the user signs
out or switches accounts, and refetched when the token's tenant or role
changes. `setSession` shares the server-verified session from
`+layout.server.ts` with `useSession()` below it.

## Reading state [#reading-state]

Each binding returns an object whose fields are reactive. A subscription
starts when a template, `$derived` or `$effect` reads one of its fields, and
stops when nothing reads it any more, so nothing runs during server
rendering. Inputs are getters, so a subscription follows the `$state` and
`$props` it reads:

```svelte title="src/lib/components/OrgCustomers.svelte"
<script lang="ts">
  import { useAuth, useLiveCount, useLiveQuery } from "better-supabase/svelte";
  import { betterSupabase } from "$lib/supabase";

  let { orgId }: { orgId: string | null } = $props();

  const auth = useAuth();
  const live = useLiveQuery(() =>
    orgId ? betterSupabase.spec.customers.findMany({ where: { orgId } }) : null,
  );
  const unread = useLiveCount(() =>
    betterSupabase.spec.messages.count({ where: { read: false } }),
  );
</script>

{#if auth.current.status === "signed-in"}
  <span data-status={live.current}>{unread.count ?? "..."}</span>
{/if}
```

| Binding                                 | Returns                                                |
| --------------------------------------- | ------------------------------------------------------ |
| `useAuth()`                             | `current`: `loading`, `signed-out` or `signed-in`      |
| `useLiveQuery(spec, options)`           | `current`: the subscription status                     |
| `useLiveCount(source, options)`         | `{ count, status, error }`                             |
| `useBroadcast(topic, values, handlers)` | `current`: the subscription status                     |
| `usePresence(topic, values, { state })` | `{ members, status, track, untrack }`                  |
| `useAction(action, { onSuccess })`      | `{ pending, pendingInputs, data, error, run, reset }`  |
| `useSession()`                          | `current`: the session from the nearest `setSession()` |

`getBetterSupabase<typeof bs>()` returns the bound client typed for your
schema, with `db`, `queries` and `supabase`. Outside a component (a module
or a test), `createBetterSvelte(bs)` returns the same object without a
context. For the server hook, see [SvelteKit](/docs/frameworks/sveltekit).

# TanStack DB

> TanStack DB collections that load through a query or through @supabase-labs/tanstack-db, and write through the repositories.

Source: https://bettersupabase.com/docs/frontend/tanstack-db

better-supabase has two ways to build a TanStack DB collection.
`better-supabase/tanstack-db/supabase` wraps Supabase's
[`@supabase-labs/tanstack-db`](https://www.npmjs.com/package/@supabase-labs/tanstack-db):
live queries load on demand through PostgREST and Realtime keeps them
current. `better-supabase/tanstack-db` loads a collection through one of
your [query options](/docs/frontend/query) instead, works with camel casing
and needs no other package.

## With @supabase-labs/tanstack-db [#with-supabase-labstanstack-db]

```bash
pnpm add @supabase-labs/tanstack-db @tanstack/react-db
```

```ts title="src/lib/collections.ts"
import { createCollection } from "@tanstack/react-db";
import { supabaseCollection } from "better-supabase/tanstack-db/supabase";
import { customersRow } from "./supabase/generated.standard";
import { betterSupabase } from "./supabase";
import { db, supabase } from "./client";

export const customers = createCollection(
  supabaseCollection(betterSupabase, supabase, "customers", {
    schema: customersRow,
    db,
    realtime: true,
  }),
);
```

`supabaseCollection(betterSupabase, supabase, table, options)` calls
`supabaseCollectionOptions()` with the table's database name and primary
key, then replaces its write handlers. `schema` is any Standard Schema for
the row: the `standardSchema()`, `zod()` or `valibot()` generator output
works. `realtime: true` subscribes to `postgres_changes` on the table, and
`realtimeUseFilter: true` narrows that subscription to each live query's
filter. Pass `queryClient` to share one with the rest of the app.

Inserts, updates and deletes run `create`, `update` and `delete` on the
table's repository, so RLS, hooks, events and plugins apply, and a failed
write throws a `DbException` that rolls the optimistic change back. The row
the repository returns goes straight into the collection. Tables with
[codec](/docs/concepts/temporal) columns refetch instead, because the
repository decodes values that the synced rows keep raw. Pass
`writes: "direct"` (and no `db`) to keep the package's own handlers, which
write through the supabase client.

The package reads and writes database column names and listens to the
`public` schema, so `supabaseCollection()` throws for a schema with
`casing: "camel"` and for tables in other schemas. Use
`collectionOptions()` for those.

For Realtime, add each table to the publication in a migration:

```sql title="supabase/schemas/realtime.sql"
alter publication supabase_realtime add table public.customers;
```

Delete events carry the replica identity of the deleted row, so the table
needs a primary key or `replica identity full`.
[`doctor`](/docs/cli/doctor#bs306) reports a published table whose delete
events carry no keys as BS306.

## Through a query option [#through-a-query-option]

```ts title="src/lib/collections.ts"
import { createCollection } from "@tanstack/react-db";
import { queryCollectionOptions } from "@tanstack/query-db-collection";
import { collectionOptions } from "better-supabase/tanstack-db";
import { betterSupabase } from "./supabase";
import { db, queries, queryClient } from "./client";

export const customers = createCollection(
  queryCollectionOptions(
    collectionOptions(betterSupabase, db, "customers", {
      query: queries.customers.findMany({ where: { status: "active" } }),
      queryClient,
    }),
  ),
);
```

`collectionOptions(betterSupabase, db, table, options)` returns the options
`queryCollectionOptions()` reads. The collection loads its rows with the
[query option](/docs/frontend/query) you pass and keys them by the table's
primary key (a JSON array for a composite key). TanStack DB is not a
dependency: the options are typed by shape.

## Writes [#writes]

`customers.insert(row)`, `customers.update(id, draft)` and
`customers.delete(id)` apply optimistically, then run `create`, `update` and
`delete` on the table's repository, so RLS, hooks, events and plugins apply.
A failed write throws a `DbException`, and TanStack DB rolls the optimistic
change back. After a write the collection refetches its query. To refresh
the other queries that read the table, call
`invalidateOnMutation(betterSupabase, queryClient)` from
`better-supabase/query` once at startup.

## Live queries [#live-queries]

Join and filter collections with TanStack DB's `useLiveQuery`. To refresh a
collection when another client changes the table, invalidate its tables
from a realtime subscription with `invalidateTables(queryClient, ["customers"])`.

# Vue

> A Vue plugin, typed composables, live queries, presence and actions from better-supabase/vue.

Source: https://bettersupabase.com/docs/frontend/vue

`better-supabase/vue` binds the [browser client](/docs/frontend/client) to
Vue 3. Install the plugin next to TanStack Vue Query:

```ts title="src/main.ts"
import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
import { betterSupabase } from "better-supabase/vue";
import { createApp } from "vue";
import App from "./App.vue";
import { bs } from "./lib/supabase/client";

const queryClient = new QueryClient();

createApp(App)
  .use(VueQueryPlugin, { queryClient })
  .use(betterSupabase(bs, { queryClient }))
  .mount("#app");
```

With `queryClient`, the plugin removes all better-supabase queries when the
user signs out or switches accounts, and refetches them when the token's
tenant or role changes. It uses
[`clearOnUserChange`](/docs/frontend/query#clearing-on-sign-out).

Create composables typed for your schema once:

```ts title="src/lib/composables.ts"
import { createBindings } from "better-supabase/vue";
import type { bs } from "./supabase/client";

export const { useDb, useQueries, useAuth, useSession } =
  createBindings<typeof bs>();
```

```vue title="src/components/CustomerList.vue"
<script setup lang="ts">
import { useQuery } from "@tanstack/vue-query";
import { useAuth, useQueries } from "../lib/composables";

const auth = useAuth();
const q = useQueries();
const { data } = useQuery(q.customers.findMany({ select: ["id", "name"] }));
</script>

<template>
  <SignIn v-if="auth.status === 'signed-out'" />
  <ul v-else>
    <li v-for="c in data" :key="c.id">{{ c.name }}</li>
  </ul>
</template>
```

## Composables [#composables]

| Composable                                | Returns                                                          |
| ----------------------------------------- | ---------------------------------------------------------------- |
| `useAuth()`                               | a ref with `loading`, `signed-out` or `signed-in`                |
| `useSupabase()`                           | the supabase-js client                                           |
| `useLiveQuery(spec, options)`             | a ref with the subscription status                               |
| `useLiveCount(source, options)`           | a reactive `{ count, status, error }`                            |
| `useBroadcast(topic, values, handlers)`   | a ref with the subscription status                               |
| `usePresence(topic, values, { state })`   | a reactive `{ members, status, track, untrack }`                 |
| `useAction(action, { onSuccess })`        | a reactive `{ pending, pendingInputs, data, error, run, reset }` |
| `provideSession(session)`, `useSession()` | a server-verified session shared with the components below       |

Inputs accept a value, a ref or a getter, so a subscription follows the
props and refs it reads. A `null` spec or `null` values pause it:

```vue title="src/components/OrgCustomers.vue"
<script setup lang="ts">
import { useLiveCount, useLiveQuery } from "better-supabase/vue";
import { betterSupabase } from "../lib/supabase";

const props = defineProps<{ orgId: string | null }>();

useLiveQuery(() =>
  props.orgId
    ? betterSupabase.spec.customers.findMany({ where: { orgId: props.orgId } })
    : null,
);
const unread = useLiveCount(() =>
  betterSupabase.spec.messages.count({ where: { read: false } }),
);
</script>

<template>
  <span>{{ unread.count ?? "..." }}</span>
</template>
```

Subscriptions start on mount, never during server rendering, and stop when
the component unmounts. The same composables work in Nuxt, where the
[Nuxt module](/docs/frameworks/nuxt) auto-imports them.

# Quickstart

> Install better-supabase, generate your schema and run your first typed query.

Source: https://bettersupabase.com/docs/getting-started

### 1. Install [#install]

```bash
pnpm add better-supabase @supabase/supabase-js
pnpm add -D pg @supabase/postgrest-typegen@0.4.0
```

The `better-supabase` command ships in the `better-supabase` package. `pg` and
`@supabase/postgrest-typegen` are optional peers that only the CLI loads: `pg`
reads your database schema, and postgrest-typegen writes `database.types.ts`.
Keep postgrest-typegen at the version above, which the CLI's output is tested
against; `gen` prints a notice for any other release, and without it, `gen`,
`introspect` and `doctor` stop with the install command. Every other peer is
optional too; [Peers](/docs/getting-started/peers) lists which subpath needs
which one. TypeScript 6 and 7 are
supported, with `nodenext` or
`bundler` module resolution. On runtimes without a native `Temporal` (Node 24,
Safari, Hermes), install `temporal-polyfill` and pass its namespace to
`defineSupabase(schema, { temporal: Temporal })`, or import
`temporal-polyfill/global` once at startup; see
[Temporal](/docs/concepts/temporal).

### 2. Configure [#configure]

Run `pnpm better-supabase init` to write the config, `src/lib/supabase/index.ts`,
and glue for the frameworks it finds (see [init](/docs/cli/init)). In a
terminal it asks for the row casing and the integrations; `--yes` takes the
defaults. Before installing anything, `npx better-supabase init` does
the same and prints the install command for your package manager. Or create
`better-supabase.config.ts` in your project root yourself:

```ts title="better-supabase.config.ts"
import { defineConfig, valibot } from "better-supabase/config";

export default defineConfig({
  casing: "camel",
  output: "src/lib/supabase/generated.ts",
  plugins: { timestamps: true, softDelete: true },
  generators: [valibot()],
});
```

Every option has a default, so an empty config works too. A JSON config
(`better-supabase.config.json`) is also supported and validated by
[`schemas/config-v1.json`](/docs/cli/config).

### 3. Generate [#generate]

Start your local stack and generate:

```bash
supabase start
pnpm better-supabase env   # URL and keys into .env.local
pnpm better-supabase gen
```

This writes `database.types.ts` (from `supabase gen types`) and
`generated.ts` with models, relation metadata and the `schema` object. Run
`better-supabase gen --check` in CI to fail on drift. As you add SQL modules,
an OpenAPI entry or a seed, `gen` runs those generators too, so it stays the
one command to run (see [gen tasks](/docs/cli/gen#tasks)).

### 4. Query [#query]

```ts
import { createClient } from "@supabase/supabase-js";
import { defineSupabase } from "better-supabase";

import { schema } from "./lib/supabase/generated.ts";

export const betterSupabase = defineSupabase(schema);

const db = betterSupabase.connect(createClient(url, publishableKey));

const customers = await db.customers
  .findMany({
    select: ["id", "name"],
    where: { status: "active", notes: { some: { kind: "call" } } },
    include: { organization: { select: ["name"] } },
    orderBy: { name: "asc" },
    limit: 20,
  })
  .orThrow();
// { id: string; name: string; organization: { name: string } }[]
```

`defineSupabase` holds no secrets and no connection. Call `connect()` per
request with the client for the current user, so RLS always applies.

## Next steps [#next-steps]

- [How codegen works](/docs/concepts)
- [Filtering](/docs/repository/filtering)
- [Results and errors](/docs/concepts/results)

# Peers

> The optional peer dependencies of better-supabase, the version ranges they accept and the subpaths that need each one.

Source: https://bettersupabase.com/docs/getting-started/peers

better-supabase has one required peer, `@supabase/supabase-js`. Every other
peer is optional: install it only when you import a subpath that needs it.
Package managers still check an optional peer you have installed for another
reason, so with `strictPeerDependencies` an install fails when the version is
outside the range below. [`doctor`](/docs/cli/doctor#bs601) reports such a
peer as BS601.

| Peer                            | Range                | Needed by                                                                                                                             |
| ------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `@supabase/supabase-js`         | `^2.116.0`           | Everything (required)                                                                                                                 |
| `@supabase/postgrest-typegen`   | `>=0.4.0 <0.5`       | The CLI: `gen`, `introspect` and `doctor`                                                                                             |
| `pg`                            | `>=8.15 <9`          | The CLI's database commands, `better-supabase/postgres`, `better-supabase/workflow-sdk/world`                                         |
| `oxfmt`                         | `>=0.66.0 <1`        | The CLI, to format `database.types.ts`                                                                                                |
| `@supabase/config`              | `>=0.9.0 <0.12`      | The CLI, to read `supabase/config.toml` (it falls back to its own parser)                                                             |
| `@supabase/lite`                | `>=0.11.0 <0.12`     | `liteStack()` in `better-supabase/testing`, and the CLI's `gen` and `doctor` on a Supabase Lite project                               |
| `@supabase/mcp-server-supabase` | `>=0.13.0 <0.14`     | `supabaseMcp` in `better-supabase/ai-sdk/mcp` and `supabaseMcpHandler` in `better-supabase/mcp/sdk`                                   |
| `temporal-polyfill`             | `>=1`                | Runtimes without a native `Temporal` ([Temporal](/docs/concepts/temporal))                                                            |
| `better-result`                 | `>=3 <4`             | The types of `better-supabase/better-result` ([better-result](/docs/concepts/better-result))                                          |
| `react`                         | `^19`                | `better-supabase/react`, `react/native`, `next/client`, `powersync/react` and the `/react` entries of blocks, `ai-sdk` and `chat-sdk` |
| `@tanstack/query-core`          | `^5.62.0`            | `better-supabase/query`, `react`, `vue`, `solid`, `svelte`, `tanstack-db`                                                             |
| `@supabase-labs/tanstack-db`    | `>=0.1.0 <0.2`       | `better-supabase/tanstack-db/supabase`                                                                                                |
| `vue`                           | `^3.3`               | `better-supabase/vue`                                                                                                                 |
| `solid-js`                      | `^1.8`               | `better-supabase/solid`                                                                                                               |
| `svelte`                        | `^5.7`               | `better-supabase/svelte`                                                                                                              |
| `next`                          | `>=16.3 <17`         | `better-supabase/next`, `next/client`, `next/image`                                                                                   |
| `@next/playwright`              | `>=16.2 <17`         | `expectInstant` in `better-supabase/testing`                                                                                          |
| `hono`                          | `>=4 <5`             | `better-supabase/hono`                                                                                                                |
| `@orpc/server`                  | `>=2.0.0-beta.40 <3` | `better-supabase/orpc`                                                                                                                |
| `@nestjs/common`                | `>=11 <13`           | `better-supabase/nestjs`                                                                                                              |
| `expo-server`                   | `>=55 <59`           | `better-supabase/expo`                                                                                                                |
| `@modelcontextprotocol/server`  | `>=2.3.0 <3`         | `better-supabase/mcp/sdk`                                                                                                             |
| `@opentelemetry/api`            | `>=1.9 <2`           | `better-supabase/otel`                                                                                                                |
| `stripe`                        | `>=17 <24`           | `better-supabase/blocks/billing`, unless you pass a client                                                                            |
| `redis`                         | `>=5 <7`             | `better-supabase/streams/redis` with the `url` option                                                                                 |
| `jsonpath-rfc9535`              | `>=1.3.0 <2`         | `better-supabase/overlay`, unless you pass a JSONPath engine                                                                          |
| `ai`                            | `>=7 <8`             | `better-supabase/ai-sdk` and its subpaths                                                                                             |
| `@ai-sdk/react`                 | `>=4 <5`             | `better-supabase/ai-sdk/react`, `ai-sdk/workflow/react`                                                                               |
| `@ai-sdk/mcp`                   | `>=2 <3`             | `better-supabase/ai-sdk/mcp`                                                                                                          |
| `@ai-sdk/workflow`              | `>=2 <3`             | `better-supabase/ai-sdk/workflow`                                                                                                     |
| `chat`                          | `>=4.41 <5`          | `better-supabase/chat-sdk`, and the `chat` state of `better-supabase/testing`                                                         |
| `workflow`                      | `>=5.1 <6`           | `better-supabase/workflow-sdk`, `workflow-sdk/builder`, `ai-sdk/workflow`                                                             |
| `@workflow/world`               | `>=5.0.2 <5.1`       | `better-supabase/workflow-sdk/world`, `workflow-sdk/builder`                                                                          |
| `@workflow/world-postgres`      | `>=5.0.2 <5.1`       | `better-supabase/workflow-sdk/world`                                                                                                  |
| `eve`                           | `>=0.71 <1`          | `better-supabase/eve`                                                                                                                 |

Two ranges are narrower than the rest. `@modelcontextprotocol/server` starts
at 2.3.0 because the MCP adapter
imports `verifyBearerToken` and `bearerAuthChallengeResponse`, which earlier
releases don't have. `@supabase/postgrest-typegen` accepts any 0.4 release,
but the CLI is tested against 0.4.0: `gen` prints a notice for any other
release, because `database.types.ts` can then differ from `supabase gen types`
output. Pin it with `pnpm add -D @supabase/postgrest-typegen@0.4.0`.
`@supabase-labs/tanstack-db` is a 0.x Supabase Labs package, so the range
stops at its next minor release.

When another package in your app needs a version outside a range, and you
don't import the subpath that needs it, allow it in `pnpm-workspace.yaml`:

```yaml title="pnpm-workspace.yaml"
peerDependencyRules:
  allowedVersions:
    "better-supabase>@modelcontextprotocol/server": "2"
```

# CSV downloads

> Turn rows into RFC 4180 CSV or a download response with toCsv, csvColumns and csvResponse from better-supabase/server.

Source: https://bettersupabase.com/docs/guides/csv-downloads

`better-supabase/server` exports the CSV writer the audit log and the data
exporter use, so an admin list or a report can offer a download without a
second CSV library.

```ts title="app/api/customers/export/route.ts"
import { csvResponse } from "better-supabase/server";

export const GET = bs.handler(async (_request, ctx) => {
  const customers = await ctx.db.customers
    .findMany({ orderBy: { name: "asc" } })
    .orThrow();
  return csvResponse(customers, {
    filename: "customers.csv",
    columns: ["name", "email", "createdAt"],
  });
});
```

`csvResponse(rows, options)` answers with `Content-Type: text/csv;
charset=utf-8` and a `Content-Disposition: attachment` header that carries the
file name as both `filename` and the UTF-8 `filename*` form, so names with
accents survive. `status` and `headers` are optional; the two headers above
always win.

| Option           | Default                           | Meaning                                                                    |
| ---------------- | --------------------------------- | -------------------------------------------------------------------------- |
| `filename`       | required                          | The download name, such as `customers.csv`                                 |
| `columns`        | every key, in order of appearance | The columns in order; `csvColumns(rows)` returns the default               |
| `escapeFormulas` | `true`                            | Prefixes text cells that start with `=`, `+`, `-`, `@`, tab or CR with `'` |
| `status`         | `200`                             | The response status                                                        |
| `headers`        | none                              | Extra headers, such as `cache-control: no-store`                           |

`toCsv(rows, { columns, escapeFormulas })` returns the same text as a string,
with a header row and CRLF line ends. Objects and arrays are written as JSON,
and `null` and `undefined` as empty cells. Leave `escapeFormulas` on for any
file a person opens in a spreadsheet: a cell such as `=HYPERLINK(...)` that a
user typed would otherwise run as a formula (CSV injection).

# Data API grants

> Grant new tables to the Data API roles now that Supabase no longer does it for you.

Source: https://bettersupabase.com/docs/guides/data-api-grants

Supabase used to grant every new table in `public` to `anon`, `authenticated`
and `service_role`, so RLS was the only gate. That default is going away: new
projects stopped getting it on May 30, 2026, and existing projects lose it for
new tables on October 30, 2026. A table without a grant fails every Data API
request with `42501 permission denied for table ...`, before any policy runs.

Tables that already exist keep their grants. The tables you add after the
cutover are the ones that break.

## Declare what each role reaches [#declare-what-each-role-reaches]

List the tables and privileges in `expose`. An array means privileges for
`authenticated`; an object sets them per role. `service_role` gets every
privilege unless the entry sets `serviceRole`. Functions are keyed by their
signature, and `execute` lists the roles that may call them (`service_role`
is added unless `serviceRole: false`).

```ts title="better-supabase.config.ts"
export default defineConfig({
  expose: {
    customers: ["select", "insert", "update", "delete"],
    organizations: ["select"],
    "public.pricing_plans": { anon: ["select"], authenticated: ["select"] },
    "public.audit_exports": { serviceRole: ["select", "insert"] },
    "search_notes(text, integer)": { execute: ["authenticated"] },
  },
  sql: { modules: ["grants"] },
});
```

Then write the grants into your declarative schema and diff a migration:

```bash
better-supabase sql add grants
supabase db schema declarative sync -f data_api_grants
```

A privilege can name columns, such as `"update(title, body)"` or
`"select(id, name)"`, for `select`, `insert` and `update`. The module writes
it as a column grant (`grant update (title, body) on table ...`), so the role
can change only those columns:

```ts title="better-supabase.config.ts"
expose: {
  "public.posts": {
    anon: ["select(id, title, published_at)"],
    authenticated: ["select", "insert", "update(title, body)"],
  },
},
```

The CLI validates the config with the same rule, so `sql sync` accepts a
column privilege in `better-supabase.config.ts` or `.json` and rejects a
string that is neither a privilege nor a column list. Doctor (BS106) checks
the table-level privileges only, since column grants don't show in a table's
privileges.

Each entry is the complete privilege set of that table or function. The
`grants` module first revokes everything from `public`, `anon`,
`authenticated` and `service_role`, then grants what the entry lists, so a
privilege you remove from `expose` is revoked on the next sync, and
`truncate`, `references` and `trigger` never reach the API roles. Tables and
functions that `expose` doesn't list keep their grants. RLS still decides
which rows a role sees.

### Grants from your policies [#grants-from-your-policies]

With `fromPolicies`, the module also derives grants for the tables that
`expose` doesn't list from the permissive policies in your declarative
schema files (your migrations only when the project has none, so a table or
policy an old migration created and a later one dropped doesn't come back),
following `drop policy` and `drop table` statements: a role named in a policy's `to` clause gets the policy's command
(`for all` gives all four), and `service_role` gets every privilege.
Restrictive policies grant nothing. A policy without `to` (or `to public`)
counts for `authenticated` only, so `anon` needs a policy that names it.
Tables in `expose` keep their listed privileges.

A table the schema files create and enable row level security on, that no
`expose` entry and no permissive policy reaches, is service-only (tables
without RLS keep their grants): the module revokes everything from `public`,
`anon` and `authenticated` and grants `service_role` every privilege, as if
`expose` listed it with `authenticated: []`. To open such a table, add a
policy for the roles that need it, or list it in `expose`. Doctor reports a
table like that which still holds grants on the live database as
[BS115](/docs/cli/doctor#bs115), until the migration is applied.

```ts title="better-supabase.config.ts"
export default defineConfig({
  schemas: ["public"],
  sql: { modules: { grants: { options: { fromPolicies: true } } } },
});
```

Only tables in the generated `schemas` are derived. Run `sql sync` after
changing a policy so the grants follow.

A grant on a table in an exposed schema is a door to the internet: anyone with
the publishable key can call the Data API as `anon`, and any signed-in user as
`authenticated`. Pair every grant with `alter table ... enable row level
security` and policies for each role and command you grant, in the same file.
A table with grants and RLS off is readable and writable by everyone; doctor
reports it as an error ([BS100](/docs/cli/doctor#bs100)). Grant `anon` only
what signed-out visitors need, and leave `truncate`, `references` and
`trigger` out ([BS111](/docs/cli/doctor#bs111)).

Tables with a `serial` column also need `usage` on the sequence for inserts
(identity columns don't). Functions called with `db.$rpc()` need `execute`.

## Find missing grants [#find-missing-grants]

`better-supabase doctor` reports [BS106](/docs/cli/doctor#bs106) for every
table the Data API roles can't reach. Tables in `expose` need the privileges
listed there. Other tables in the generated schemas need at least `select`
for `authenticated`, except the tables with RLS on that `fromPolicies` makes
service-only.

Some tables are for the server only: an audit log or a job queue that only
`service_role` reads and writes. Revoke the Data API roles and set
`serviceRole: true` on the table, which keeps its generated types for the
admin client:

```sql title="supabase/schemas/audit_logs.sql"
revoke all on table public.audit_logs from public, anon, authenticated;
grant select, insert on table public.audit_logs to service_role;
```

```ts title="better-supabase.config.ts"
export default defineConfig({
  tables: { audit_logs: { serviceRole: true } },
});
```

Doctor then reports BS106 if `anon` or `authenticated` can still reach the
table. `tables: { internal_x: { exclude: true } }` also silences BS106, and
also leaves the table out of the generated models.

If `supabase/config.toml` sets `[api] auto_expose_new_tables = false`, the
finding says so: that's the local equivalent of the new hosted default, and
it's the setting to use locally so you hit missing grants before production
does.

## At runtime [#at-runtime]

A missing grant comes back as a `forbidden` `DbError` (status 403). Its
`hint` keeps PostgREST's `GRANT ...` suggestion and adds a pointer to
`expose`, so the log line tells you what to fix:

```ts
const { error } = await db.pricingPlans.findMany();
// error.kind === 'forbidden'
// error.hint === 'Grant the required privileges ... Supabase no longer grants new tables ... add pricing_plans to `expose` ...'
```

RLS violations (`new row violates row-level security policy`) are also
`42501` and also `forbidden`, but have no grant hint, because granting would
not help.

# Limitations

> What PostgREST can't do, and what better-supabase does instead.

Source: https://bettersupabase.com/docs/guides/limitations

Most apps talk to Supabase over PostgREST. It's the right default, and the
reason some things you'd expect from an ORM aren't available there. When
they matter, use a direct connection from `better-supabase/postgres`; the
repository API stays the same.

## No transactions over PostgREST [#no-transactions-over-postgrest]

Every PostgREST request is its own transaction. Two calls can't commit
together, and a nested write (`create` with related rows) is several
requests.

Instead:

* Put multi-step writes in a database function and call it with
  `db.$rpc()`. Declare what it changes with
  [`betterSupabase.defineRpc`](/docs/concepts/caching#writes-invalidation-targets) so
  caches refresh.
* On the server, use `postgres.transaction()` with
  [`postgresExecutor`](/docs/auth/postgres). The same `db` calls then run in
  one transaction, as the user or as admin.

## Relation filters on writes [#relation-filters-on-writes]

`where: { notes: { some: ... } }` works on reads. PostgREST can't filter an
`update` or `delete` by a related table, so those fail with
`invalid_request` before a request is sent. Read the keys first, or use a
database function or a direct connection, where the same `where` compiles
to `exists (...)`.

## `every` on to-many relations [#every-on-to-many-relations]

`every` compiles to "no row that fails the condition", which is correct for
empty relations too. `every: {}` is always true and is dropped. Over
PostgREST it costs an embedded anti-join per condition; for large relations,
prefer a column or a view that stores the answer.

## Counts [#counts]

`_count` includes and `count: 'exact'` run `count(*)` under RLS. On large
tables use `count: 'estimated'` for pagination, and only count the
relations you show.

## Jobs over PostgREST [#jobs-over-postgrest]

The [jobs block](/docs/blocks/jobs) prefers a direct connection. Its PostgREST
transport uses the `pgmq_public` functions, which can send, read and
archive. It can't:

* deduplicate: `dedupeKey` fails with `invalid_request`,
* schedule or unschedule cron jobs: both fail with `invalid_request`,
* extend a lease: `extend` returns `false`, so heartbeats do nothing,
* record `lastError` or back off: a failed job reappears when its lease
  runs out, and is archived as dead after `maxAttempts`.

Workers that need these run on the server with `createPostgres()`.

## Acting as a user without Postgres [#acting-as-a-user-without-postgres]

`bs.forContext` and `bs.actingAs` run work as a user who isn't calling, for
jobs and webhooks. They need a direct Postgres connection, because they set
the user's claims for the transaction themselves. The Data API can't do
that: PostgREST only trusts tokens signed by the project's signing keys,
and with asymmetric keys only Supabase Auth holds the private key, so the
app can't mint a token for the user (see
[Why only over Postgres](/docs/auth/impersonation#why-only-over-postgres)).

An Edge Function with only the Data API can therefore act as its caller, but
not as anyone else. Run user-scoped workers where a
[pooler URL](/docs/auth/postgres#which-connection-string) is available, or
keep the work inside the user's own request. Don't switch to the service
role to get around it: that bypasses RLS for the whole job.

## Live queries [#live-queries]

[Live queries](/docs/frontend/live-queries) refetch the whole query on a
change, one request per affected query. They don't patch rows in place and
don't see tables outside `realtime.tables`.

## Generated types [#generated-types]

`gen` needs the database: a local connection, or the Management API for a
hosted project. It can't infer types from migrations alone. Commit the
snapshot and run `better-supabase gen --check` in CI.

# Monorepos

> One runtime package owns defineSupabase and the request context; domain packages type their repositories from its db and never import the generated client.

Source: https://bettersupabase.com/docs/guides/monorepo

In a monorepo, keep one package that owns the Supabase definition and the
request context, and let every domain package take its `db` as a parameter.
The generated client then has one importer, a schema change regenerates one
file, and domain code works the same in a route handler, a job or a test.

```text
packages/
  runtime/   defineSupabase, the generated client, the server, the context types
  crm/       customers repositories: import type { Db } from '@acme/runtime'
  commerce/  shipping repositories: import type { Db } from '@acme/runtime'
apps/
  web/       bs.route and bs.action call crm and commerce with ctx.db
```

## The runtime package [#the-runtime-package]

```ts title="packages/runtime/src/index.ts"
import { defineSupabase } from "better-supabase";
import { createEdge, type EdgeOptions } from "better-supabase/edge";
import {
  type AuthSession,
  type ServerContext,
  toSession,
} from "better-supabase/server";

import { type Functions, type Models, schema } from "./generated.ts";

export const betterSupabase = defineSupabase(schema);

export type AppContext = ServerContext<Models, Functions, unknown>;
export type Db = AppContext["db"];

export function sessionOf(ctx: AppContext): AuthSession {
  return toSession(ctx.auth);
}

export function createRuntime(options: EdgeOptions) {
  return createEdge(betterSupabase, options);
}
```

Point `better-supabase gen` at this package (`"output": "src/generated.ts"` in
its `better-supabase.config.json`). A Next.js app calls `createNext(betterSupabase, ...)`
with the same `betterSupabase`, so every adapter shares one definition.

## The CLI in a workspace [#the-cli-in-a-workspace]

Run the CLI from any package, or pass `--cwd packages/runtime`. The
`supabase` directory usually stays at the repository root, and the CLI finds
it the way the Supabase CLI does: it reads `supabase/config.toml` from `--cwd`
or the nearest parent directory that has one, and stops at the directory that
holds `.git`. Ports, Auth hooks, `schema_paths` and the migrations then come
from that directory, and `keys` writes `signing_keys.json` next to it.

`better-supabase.config.*` is found the same way: the CLI loads the first one
it meets walking up from `--cwd`, so a single config at the repository root
serves every package, and `pnpm --filter @acme/runtime exec better-supabase gen`
needs no `--cwd`. Paths inside the config are relative to the directory that
holds the config file. `--config` takes precedence and stays relative to
`--cwd`.

With the config in the package, point the SQL modules and the seed at the root
`supabase` directory when you use them:

```ts title="packages/runtime/better-supabase.config.ts"
export default defineConfig({
  output: "src/generated.ts",
  sql: { dir: "../../supabase/schemas", testsDir: "../../supabase/tests" },
  seed: { output: "../../supabase/seeds/000_better_supabase.sql" },
});
```

`@supabase/postgrest-typegen`, which `gen` loads to match `supabase gen
types`, pins `oxfmt` as an optional peer. When the workspace uses a newer
`oxfmt`, pnpm reports a peer mismatch; allow it in `pnpm-workspace.yaml`:

```yaml title="pnpm-workspace.yaml"
peerDependencyRules:
  allowedVersions:
    "@supabase/postgrest-typegen>oxfmt": "*"
```

## Domain packages [#domain-packages]

Domain packages import only types from the runtime:

```ts title="packages/crm/src/customers.ts"
import type { Db } from "@acme/runtime";

export function activeCustomers(db: Db) {
  return db.customers.findMany({
    select: ["id", "name", "status"],
    where: { status: "active" },
    orderBy: { name: "asc" },
  });
}
```

Rows keep the runtime's casing, the `status` column keeps its enum type, and
the function returns a `Result`, so a route handler can return it as is.
Because `Db` is a type import, the domain package has no runtime dependency
on the generated client and no import cycle with the app.

The
[monorepo example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/monorepo)
has this layout as workspace packages: `runtime`, `crm` and `billing`, and a
Hono `api` that passes the caller's `db` to both. Its domain packages carry a
Turbo boundaries tag that rejects a dependency from one domain package on
another:

```json title="turbo.json"
{
  "boundaries": {
    "tags": { "domain": { "dependencies": { "deny": ["domain"] } } }
  }
}
```

To keep the boundary inside one package, add a test that fails when a domain
file imports anything but the runtime. The
[`validation-monorepo`](https://github.com/ScaleDockHQ/better-supabase/tree/main/tests/validation-monorepo)
workspace in this repository has one, plus type tests that pin each
repository's parameter to the runtime's `Db`.

## Bearer callers in domain code [#bearer-callers-in-domain-code]

`sessionOf(ctx)` gives domain code the caller as plain data. For an OAuth
client or an agent, `session.actor` and `session.delegation` say who acts
for the user and with which scopes (see
[bearer callers](/docs/frameworks/next#bearer-callers)). Pass the session
down when a domain function records who made a change; RLS keeps deciding
the rows.

## Errors across packages [#errors-across-packages]

Domain packages that use [better-result](https://github.com/dmmulroy/better-result)
convert at the boundary with `toBetterResult(result, Result, mapError)` and
back with `fromBetterResult(value)`. A `DbError`, or an error whose `cause`
is one, survives the round trip; other errors go through `mapError`. Both
work on `Result` and `AsyncResult`.

# Offline-first

> Read and write on the device with PowerSync, and upload the changes through the repositories.

Source: https://bettersupabase.com/docs/guides/offline-first

An offline-first app keeps two sets of repositories over the same
definition:

* `local`: [`powersyncExecutor`](/docs/repository/powersync) over the PowerSync
  database. Screens read and write here, online or not.
* `bs.db`: the [React Native client](/docs/frontend/react-native) over
  PostgREST, as the signed-in user. Only the upload path uses it.

PowerSync syncs rows down and queues local writes. Its connector's
`uploadData` sends the queue to your backend; replaying each change through
`bs.db` means RLS, plugins and validation run on it, exactly as for an online
write.

## Upload through the repositories [#upload-through-the-repositories]

Each queued change has an `op`: `PUT` (insert or replace), `PATCH` (update the
changed columns) or `DELETE`. `createUploadConnector` maps each one to the
table's repository and authenticates sync with the Supabase session:

```ts title="src/lib/powersync/connector.ts"
import { createUploadConnector } from "better-supabase/powersync";
import { bs, supabase } from "../supabase/native";

export const connector = createUploadConnector({
  endpoint: process.env.EXPO_PUBLIC_POWERSYNC_URL,
  supabase,
  tables: { customers: bs.db.customers },
});
```

It reads the queue in batches of 100 (`batchSize`). Every table that takes
local writes needs a route: a change to a table missing from `tables` stops
the upload with an error that names it, so no change is dropped without a
trace. A route can also be a function of the change, for a table that needs
custom replay:

```ts
tables: {
  customers: bs.db.customers,
  notes: (entry) => bs.db.notes.upsert({ ...renameColumns(entry.opData), id: entry.id }),
},
```

`opData` holds the columns as SQLite stored them, under their database names;
with `casing: 'camel'`, use a function route that renames them to the app
keys.

To sync only while someone is signed in, pass the connector to
`syncWithAuth`. It connects on sign-in, and on sign-out or a change of user it
disconnects and clears the local database, so one user never reads another's
rows. Unsynced changes are cleared with them; pass `clearOnSignOut: false` to
keep the database.

```tsx title="src/lib/powersync/sync.ts"
import { syncWithAuth } from "better-supabase/powersync";

export function useSync(): void {
  useEffect(() => syncWithAuth(powersync, bs.auth, { connector }), []);
}
```

## Retry, discard or surface a conflict [#retry-discard-or-surface-a-conflict]

A failed upload returns a [`DbError`](/docs/concepts/results). Its `kind`
decides what happens to the change; `uploadOutcome(kind)` is the default
below, and `classify` overrides it for some errors:

| Kind                                                                                                             | Do                                                            |
| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| `network`, `timeout`, `aborted`, `rate_limited`, `quota_exceeded`, `serialization`, `unauthorized`, `unexpected` | retry: PowerSync keeps the batch and calls `uploadData` again |
| `conflict`, `stale`, `foreign_key`                                                                               | conflict: complete it and show the conflict next to the row   |
| `forbidden`, `not_found`, `check`, `not_null`, `validation` and the rest                                         | discard: complete it; the server will never accept it         |

Retrying a change the server will never accept blocks the queue for good,
so the kinds that can't succeed on a retry complete. Completing removes the
change from the queue, and the next sync replaces the local row with the
server's version, so the device ends up where the server is. An
`unauthorized` error retries while the session lives; once the refresh token
is dead, the upload stops until the user signs in again.

## Conflicts [#conflicts]

The server's row wins once the change is gone from the queue. The connector
keeps every conflict and discarded change in `connector.rejected` (and calls
`onConflict` or `onDiscard`), with the queued `entry` and the error.
`useConflicts(connector)` from `better-supabase/powersync/react` reads them in
a component: show each on its row, let the user apply it again as a new
write, then `dismiss` it. A `conflict` error carries the `constraint` and
`columns` that rejected it, which is enough to point at the field.

For edits that must not overwrite a newer server row, record the
`updated_at` the user saw and pass it as `expect`:
`customers.update(id, data, { expect: { updated_at: seenAt } })` returns a
`stale` error when someone else changed the row first, and the user decides.

The [Expo example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/expo-powersync)
uses this connector with `syncWithAuth` and `useConflicts`.

# Read replicas

> Send reads to a Supabase read replica and keep read-your-writes after a mutation.

Source: https://bettersupabase.com/docs/guides/read-replicas

A [read replica](https://supabase.com/docs/guides/platform/read-replicas) has
its own API URL. Point `SUPABASE_READ_URL` at it and the server sends each
request's reads there and its writes to the primary:

```bash title=".env"
SUPABASE_URL=https://abc.supabase.co
SUPABASE_READ_URL=https://abc-rr-eu-west-1-xyz.supabase.co
```

Or pass it in code, where `readUrl: false` turns an env value off:

```ts title="lib/supabase/server.ts"
import "server-only";
import { createNext } from "better-supabase/next";
import { betterSupabase } from "./index";

export const bs = createNext(betterSupabase, {
  readUrl: process.env.REPLICA_URL,
  replicas: { pinMs: 5000 },
});
```

Nothing else changes: `ctx.db` routes each call.

| Call                                                                    | Goes to         |
| ----------------------------------------------------------------------- | --------------- |
| `findMany`, `findById`, `count`, `aggregate`, ...                       | replica         |
| `create`, `update`, `upsert`, `delete`                                  | primary         |
| `stable` RPCs and [read sets](/docs/repository/read-sets) (sent as GET) | replica         |
| other RPCs                                                              | primary         |
| anything after a write in the same request                              | primary         |
| `admin()`, `dbFor(userId)`                                              | primary, always |

## Read your own writes [#read-your-own-writes]

A replica lags the primary by milliseconds to seconds. A page that reads
right after saving could miss the row it just wrote. So a successful write
*pins* the request to the primary: every later read in that request goes
there too.

The pin also crosses the redirect. When a Next.js action or route, a Hono
or edge handler, or an oRPC procedure served by `bs.fetchHandler` writes, the
response sets an `HttpOnly` cookie `bs-primary-until` that holds a timestamp
`replicas.pinMs` (5 s by default) ahead. Custom adapters get the same cookie
from `ctx.apply(response)`. Every request with that cookie reads
from the primary until then, so the page you redirect to shows the new data.

Writes through `db.$client` or `ctx.sql` bypass the router. Pin by hand after
them:

```ts
await ctx.db.$client.rpc("import_customers", { rows });
ctx.replica?.pin();
```

`ctx.replica` is `undefined` when no read URL is set.

## Other adapters [#other-adapters]

`bs.context(request)` reads the cookie for every adapter, so Hono, oRPC
and edge functions honor a pin set by Next.js. Only `bs.action` and
`bs.route` set it. Elsewhere, set it yourself when `ctx.replica?.wrote` is
true:

```ts
import { PRIMARY_COOKIE } from "better-supabase/server";

if (ctx.replica?.wrote) {
  response.headers.append(
    "set-cookie",
    `${PRIMARY_COOKIE}=${Date.now() + 5000}; Path=/; Max-Age=5; HttpOnly; SameSite=Lax`,
  );
}
```

## Caveats [#caveats]

* Realtime, Storage and Auth always use the primary URL.
* A replica rejects writes. A `volatile` function called with `get: true`
  fails there, so mark only read-only functions `stable`.
* The pin is per browser. Another user can still read stale rows for up to
  the replica lag.

# Supabase library MCP blocks

> Add typed repositories to the MCP server and headless app blocks from the Supabase library, or replace their tools with createMcp.

Source: https://bettersupabase.com/docs/guides/supabase-blocks

The [Supabase library](https://supabase.com/library) ships two blocks that put
an MCP server for your users into your project: the
[MCP server](https://supabase.com/library/docs/headless/mcp) block
(`@supabase/mcp`) and the
[headless app](https://supabase.com/library/docs/tanstack/headless-app)
template (`@supabase/headless-app-tanstack`), which adds sign-in and OAuth
consent pages around the same server. Both install an Edge Function in
`supabase/functions/mcp` that runs every tool as the signed-in user. They are
shadcn registry items from Supabase, not [better-supabase blocks](/docs/blocks).

better-supabase fits in three ways, from the smallest change to the largest:

1. Keep the block and add typed repositories to its pipeline.
2. Replace the block's tools with [`createMcp`](/docs/frameworks/mcp), which
   generates tools from your tables.
3. Keep an official SDK server and add
   [`better-supabase/mcp/sdk`](/docs/frameworks/mcp#official-mcp-sdk).

## Requirements [#requirements]

Both blocks need the same project settings. Use Supabase CLI 2.117 or later:
it supplies asymmetric signing keys locally and passes the function slug to
the Edge Function, so the URLs in the OAuth metadata are the public ones.

* Asymmetric signing keys (ES256 or RS256). Locally, run
  [`better-supabase keys`](/docs/cli/local#keys) and point
  `[auth] signing_keys_path` at the file; on the hosted project, rotate to an
  asymmetric key under JWT Keys.
* The gateway's JWT check turned off for the function. The function verifies
  tokens itself, and the gateway's own 401 has no `WWW-Authenticate`
  challenge, so MCP clients could not discover how to sign in.
* The Supabase Auth OAuth server, so external MCP clients can sign users in.
  It sends users to a consent page served from the Auth Site URL: the
  headless app's own, or one in your app (see
  [OAuth consent](/docs/auth/oauth-consent) for the page and a
  connected-agents list that revokes grants).

```toml title="supabase/config.toml"
[functions.mcp]
verify_jwt = false

[auth.oauth_server]
enabled = true
authorization_url_path = "/oauth/consent"
allow_dynamic_registration = true
```

`allow_dynamic_registration` lets any compatible client register itself; set
it to `false` if you register clients yourself. Get an existing project's
config, schema and Edge Functions into the repo first with `supabase pull`
(Supabase CLI 2.119 or later), then add a block:

```bash
npx shadcn@latest add @supabase/mcp
```

The function imports better-supabase through its `deno.json`:

```json title="supabase/functions/mcp/deno.json"
{
  "imports": {
    "better-supabase": "npm:better-supabase@^0.6",
    "better-supabase/": "npm:/better-supabase@^0.6/"
  }
}
```

## Typed repositories in the block's pipeline [#typed-repositories-in-the-blocks-pipeline]

The block's `index.ts` is a `pipeline` from `@supabase/middleware`:
`withOAuthProtectedResource()` serves the RFC 9728 metadata, and
`withSupabase({ auth: 'user' })` verifies the token and builds a user-scoped
client. Add `withBetterDb(betterSupabase)()` from
`better-supabase/server` after `withSupabase`, and pass the `ctx.db` it adds
to the tools:

```ts title="supabase/functions/mcp/index.ts"
import { withBetterDb } from "better-supabase/server";

import { betterSupabase } from "../_shared/supabase.ts";

// ...the block's imports, createServer and CORS_HEADERS stay as they are.

Deno.serve(
  pipeline(
    [
      withOAuthProtectedResource(),
      withSupabase({ auth: "user", cors: { headers: CORS_HEADERS } }),
      withBetterDb(betterSupabase)(),
    ],
    (request, ctx) =>
      createMcpHandler(() =>
        createServer({
          supabase: ctx.supabase,
          db: ctx.db,
          userClaims: ctx.userClaims!,
          jwtClaims: ctx.jwtClaims!,
        }),
      ).fetch(request),
  ),
);
```

`ctx.db` holds repositories bound to `ctx.supabase`, so every query still
goes through RLS, and their context comes from the verified claims (see
[`@supabase/server` pipelines](/docs/auth/middleware)). Add it to the block's
`ToolContext`:

```ts title="supabase/functions/mcp/tools/types.ts"
import type { betterSupabase } from "../../_shared/supabase.ts";

export type ToolContext = {
  supabase: SupabaseClient;
  db: ReturnType<typeof betterSupabase.connect<SupabaseClient>>;
  userClaims: NonNullable<SupabaseContext["userClaims"]>;
  jwtClaims: NonNullable<SupabaseContext["jwtClaims"]>;
};
```

Then a tool reads typed rows:

```ts title="supabase/functions/mcp/tools/customers.ts"
import type { McpServer } from "npm:@modelcontextprotocol/server@2.0.0";

import { errorResult, jsonResult } from "./result.ts";
import type { ToolContext } from "./types.ts";

export function registerCustomersTools(
  server: McpServer,
  { db }: ToolContext,
): void {
  server.registerTool(
    "list_customers",
    {
      description: "List the customers the signed-in user can see.",
      annotations: { readOnlyHint: true, openWorldHint: false },
    },
    async () => {
      const result = await db.customers.findMany({
        select: ["id", "name", "status"],
        limit: 50,
      });
      return result.ok
        ? jsonResult(result.data)
        : errorResult(result.error.message);
    },
  );
}
```

Register it in `tools/index.ts` next to `registerWhoamiTool`. Repository
methods return a `Result` instead of throwing, so the tool turns a `DbError`
into an MCP error result the model can read; the block's
`runtimeErrorResult` stays the helper for code that throws.
`_shared/supabase.ts` exports `betterSupabase = defineSupabase(schema)` from
the client [`better-supabase gen`](/docs/cli/gen) writes.

`withBetterSupabase` from `better-supabase/server` is a pipeline entry that
resolves the caller itself (see [Middleware](/docs/auth/middleware)); next to
the block's `withSupabase` use `withBetterDb` as above. For servers on the
official SDK, `withBetterSupabaseMcp` from `better-supabase/mcp/sdk` wraps
`McpServer.registerTool` for
[`createMcpAuth`](/docs/frameworks/mcp#official-mcp-sdk).

## Replacing the tools with createMcp [#replacing-the-tools-with-createmcp]

`createMcp` replaces both `withOAuthProtectedResource` and `withSupabase` in
`index.ts`. It serves the MCP endpoint, the RFC 9728 metadata and the
`WWW-Authenticate` challenge itself, answers CORS preflights, and generates
tools from your tables. On Edge Functions it derives the public URL from the
function slug and the gateway's forwarded headers, as the block does, and
serves the metadata at `/functions/v1/mcp/oauth-protected-resource`. Keep the
block's `.env.example`, add the imports above to `deno.json`, and replace
`index.ts`:

```ts title="supabase/functions/mcp/index.ts"
import { createMcp } from "better-supabase/mcp";

import { betterSupabase } from "../_shared/supabase.ts";

const bs = createMcp(betterSupabase, {
  name: Deno.env.get("MCP_SERVER_NAME") ?? "app",
  version: "1.0.0",
  instructions: Deno.env.get("MCP_SERVER_DESCRIPTION"),
  resources: { customers: { select: ["id", "name", "status"] } },
});

Deno.serve(bs.fetch);
```

The headless app's consent page, connected-agents page and `tasks` table keep
working; only the tool code changes. See
[MCP servers](/docs/frameworks/mcp) for custom tools, `authorize` and scopes,
and [OAuth consent](/docs/auth/oauth-consent) to build those two pages in an
app that doesn't use the headless template.

To keep the block's `McpServer` and its tools instead, use `createMcpAuth`
and `withBetterSupabaseMcp` from `better-supabase/mcp/sdk`. They need
`@modelcontextprotocol/server` 2.3 or later, and the block pins 2.0.0, so
bump the import in every tool file first.

## Schema and deploy [#schema-and-deploy]

The headless app keeps its tables in `supabase/schemas`. Generate the
migration with pg-delta, then deploy:

```bash
supabase db schema declarative sync -f create_tasks
supabase link --project-ref <project-ref>
supabase db push
supabase config push
supabase secrets set --env-file supabase/functions/.env
supabase functions deploy mcp
```

`supabase config pull` brings settings changed in the dashboard back into
`config.toml`. Run [`better-supabase doctor`](/docs/cli/doctor) before the
push to check grants, RLS and the Auth hook.

# Jobs, webhooks and agents without a session

> Run background jobs, webhook handlers and MCP tools as a specific user, so RLS keeps deciding, and keep the service role for the work no user owns.

Source: https://bettersupabase.com/docs/guides/without-a-session

A request from the browser carries the user's token, so RLS decides what it
can see. Background jobs, webhooks and AI agents have no browser session, and
the shortcut there is the service role, which bypasses RLS. One job written
that way and your policies stop deciding anything for it.

better-supabase gives each of these paths an explicit identity instead:

| Path                  | Runs as                                     | How                                    |
| --------------------- | ------------------------------------------- | -------------------------------------- |
| MCP tools             | the signed-in user, from their bearer token | `createMcp` or `withBetterSupabaseMcp` |
| A job                 | the user who enqueued it, in their tenant   | `bs.forContext(job.context)`           |
| A webhook or a script | a user you look up from the event           | `bs.actingAs(userId, { tenant_id })`   |
| Work no user owns     | the service role, bypassing RLS             | `bs.admin()`, on purpose and reviewed  |

`forContext` and `actingAs` run over direct Postgres with the user's claims
set for the transaction, the way PostgREST does after checking a token, so
policies see `auth.uid() = userId`. Both need
[`postgres`](/docs/auth/postgres) in `createServer`.

## Jobs [#jobs]

Record who asked for the work when you enqueue it. `db.$context` is the
caller's context in a server handler, an MCP tool or an oRPC procedure:

```ts title="app/invoices/actions.ts"
await jobs.enqueue("send_invoice", { invoiceId }, { context: db.$context });
```

The job stores the actor and the tenant next to the payload. In the worker,
`bs.forContext(job.context)` returns repositories that run as that user, in
that tenant:

```ts title="worker.ts"
await jobs.work("send_invoice", async (payload, job) => {
  const db = await bs.forContext(job.context).orThrow();
  const invoice = await db.invoices.findById(payload.invoiceId).orThrow();
  await sendInvoice(invoice);
});
```

If the user lost access to the invoice between the request and the job, RLS
refuses it and the job fails like any other error. A job that recorded no
user, such as one a cron schedule enqueued, fails with `forbidden`:
`forContext` never falls back to the service role. When the context carries
an impersonating admin, the `act` claim comes back with it, so the
[audit log](/docs/auth/impersonation#what-gets-recorded) still names the
admin. Pass `{ reason }` to record why.

`bs.admin(job.context)` and `admin.$with(job.context)` stamp `createdBy` and
apply the `tenant()` plugin's filter, but they still run as the service role,
so RLS does not apply. Use them only for work no user owns.

### Claims from an access token hook [#claims-from-an-access-token-hook]

A user's token can carry claims that a
[custom access token hook](https://supabase.com/docs/guides/auth/auth-hooks/custom-access-token-hook)
added, such as a role or a list of organizations. A job has no token, so
`forContext` starts from `sub`, `role: "authenticated"` and the recorded
`tenant_id`. When your policies read other claims, rebuild them with
`claimsFor`:

```ts title="lib/server.ts"
const postgres = createPostgres();

export const bs = createServer(betterSupabase, {
  postgres,
  claimsFor: async (userId) => {
    const [profile] = await postgres.admin.queryRaw<{ role: string }>(
      "select role from public.profiles where id = $1",
      [userId],
    );
    return { user_role: profile?.role ?? "member" };
  },
});
```

`claimsFor` runs for every `forContext` call, so the claims reflect the
user's access when the job runs, not when it was enqueued. `sub`, `role`,
`tenant_id` and `act` always come from the context.

## Webhooks [#webhooks]

A webhook belongs to whoever the event is about. Verify and store it with
the [webhook inbox](/docs/blocks/jobs#webhook-inbox), map the provider's id to
a user and tenant in the handler, then act as that user:

```ts title="app/api/webhooks/billing/route.ts"
await inbox.process(async (message) => {
  const owner = await ownerOfCustomer(message.payload.customer);
  const db = bs.actingAs(owner.userId, { tenant_id: owner.organizationId });
  await db.subscriptions.update(owner.subscriptionId, {
    status: message.payload.status,
  });
});
```

`actingAs` trusts its caller, so call it only after the signature check and
the lookup. The same applies to scripts and support tools; for an admin
acting on a user's behalf, pass `{ actor, reason }` as the third argument (see
[Impersonation](/docs/auth/impersonation#acting-as-a-user-in-code)).

## Organizations [#organizations]

There is no organization principal. Organization-wide work acts as a member
of the organization (its owner, or the user who set the work up), with the
organization as `tenant_id`, so membership policies still apply. Work that
spans every organization, such as a nightly cleanup, is the case for
`bs.admin()` with a worker that passes `allTenants: true`.

## MCP servers and agents [#mcp-servers-and-agents]

`createMcp` and `withBetterSupabaseMcp` verify the caller's Supabase access
token and bind every tool to that user, so RLS decides what the model can
read and change. There is no service-role mode. Agents that act through an
OAuth client or a token exchange can be limited further with
`requiredScopes` and `authorize`; see [MCP servers](/docs/frameworks/mcp).
A tool that starts background work enqueues it with `{ context: db.$context }`,
and the job then runs as the same user.

## Without a direct Postgres connection [#without-a-direct-postgres-connection]

`forContext` and `actingAs` need a database connection, because the Data API
can't take claims without a token, and with asymmetric signing keys only
Supabase Auth can mint one. An Edge Function with only the Data API has no
way to act as a user that isn't calling it. Run user-scoped jobs where a
[pooler URL](/docs/auth/postgres#which-connection-string) is available, or
keep the work inside the user's own request. See
[Limitations](/docs/guides/limitations#acting-as-a-user-without-postgres).

# Introduction

> A strongly typed layer over Supabase for apps, APIs, MCP servers and jobs.

Source: https://bettersupabase.com/docs

`better-supabase` is one package that removes the glue code every Supabase app
rewrites: auth wiring, typed repositories, pagination, includes, nested
filters, soft delete, timestamps, upserts, cache invalidation, list pages,
storage paths and realtime topics.

## Built on Supabase [#built-on-supabase]

It is built directly on Supabase's own packages:

* [`@supabase/supabase-js`](https://github.com/supabase/supabase-js) for the
  per-request clients, Storage, Realtime and Edge Functions,
* [`@supabase/server`](https://github.com/supabase/server) for local token
  verification, auth modes and the env shape,
* [`@supabase/middleware`](https://github.com/supabase/middleware) for
  composable context (`ctx.supabase`, `ctx.postgres`),
* [`@supabase/ssr`](https://github.com/supabase/ssr) for cookie sessions,
* [`@supabase/postgrest-typegen`](https://www.npmjs.com/package/@supabase/postgrest-typegen)
  for the `database.types.ts` the CLI writes, the same file
  `supabase gen types` writes.

[Supabase packages](/docs/supabase-packages) lists every package, how it is
installed and how it relates to the `supabase` CLI.

## What you get [#what-you-get]

* **A CLI that generates stronger types.** `better-supabase gen` wraps
  `supabase gen types` and adds what it leaves out: relationship cardinality,
  unique keys, CHECK-constraint unions, typed jsonb, column maps for
  `camelCase` apps and per-table flags.
* **A typed repository.** `db.customers.findMany({ where, include, orderBy })`
  compiles to a single PostgREST request, or to SQL on the direct-Postgres
  path. Every call returns a `Result`; `.orThrow()` is opt-in.
* **Auth glue that avoids network calls.** Valid access tokens never touch the
  Auth server. Refresh happens once, in the proxy, and is single-flighted.
* **Framework adapters.** Next.js, Hono, oRPC, Supabase Edge Functions and a
  minimal MCP server.
* **Frontend helpers.** TanStack Query options derived from the query, with
  table-based invalidation and optimistic updates.
* **Standards on request.** Standard Schema, JSON Schema, OpenAPI, RFC 9457
  problem details, OpenTelemetry, CloudEvents, Standard Webhooks and more.

## Next steps [#next-steps]

- [Quickstart](/docs/getting-started)
- [Repository API](/docs/repository)
- [Auth and sessions](/docs/auth)
- [Standards](/docs/standards)

# From 0.1 to 0.2

> What to change when upgrading to better-supabase 0.2, which renames claims and SQL functions to one claim contract.

Source: https://bettersupabase.com/docs/migration/0.1-to-0.2

0.2 renames claims and SQL functions to one claim contract, shared with
authorization libraries that write the same claims. There are no compatibility
options: follow the steps below, regenerate, and run your tests.

## Claims [#claims]

The active tenant claim is `tenant_id` instead of `org_id`, in the token and
in `app_metadata`. Update your custom access token hook, anything that sets
`app_metadata.org_id` through the Auth admin API, and your claims schema:

```ts title="src/lib/claims.ts"
export const Claims = v.looseObject({
  tenant_id: v.optional(v.pipe(v.string(), v.uuid())),
  app_metadata: v.optional(
    v.looseObject({ tenant_id: v.optional(v.pipe(v.string(), v.uuid())) }),
  ),
});
```

To keep another name, set it once in `better-supabase.config.ts` instead of
per plugin. `plugins.tenant.claim` is gone:

```ts title="better-supabase.config.ts"
export default defineConfig({
  claims: { tenant: "org_id" },
});
```

Use loose objects (valibot `looseObject`, zod `z.looseObject`) for claims
schemas, so claims your schema doesn't list reach the code that reads them.

## SQL kit [#sql-kit]

Run `better-supabase sql sync` (or `sql add` again) after upgrading. The
modules change as follows:

| 0.1                                                                                      | 0.2                                                                                               |
| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `better_supabase.current_org_id()`                                                       | `better_supabase.current_tenant_id()`                                                             |
| `membership_claims()` in the entitlements module, `[{ tenant_id, roles, entitlements }]` | `membership_claims()` in the tenant module, `[{ scope, id, roles }]`                              |
| Plan features inside each membership                                                     | `feature_claims()` in the entitlements module, `{ [tenantId]: string[] }` in the `features` claim |

Replace `current_org_id()` in your own policies and functions. A hook that
used `membership_claims()` now sets two claims:

```sql
claims := jsonb_set(claims, '{memberships}', better_supabase.membership_claims(uid));
claims := jsonb_set(claims, '{features}', better_supabase.feature_claims(uid));
```

`sql add tenant` stops when another access token hook owns the `memberships`
claim. Pass `--force` to write it anyway. Since 0.6 that hook is declared by
an [authorization provider](/docs/extending/authorization-providers); see
[From 0.5 to 0.6](/docs/migration/0.5-to-0.6).

## TypeScript [#typescript]

* `MembershipClaim` has the shape `{ scope, id, roles }` (and
  optional `within`, `via`, `expiresAt`). Code that read `tenant_id` or
  `entitlements` from a membership reads `id` and the `features` claim.
* `hasEntitlement(session, tenantId, key)` reads the `features` claim. Pass
  the claim name as a fourth argument if you renamed it.
* `EntitlementKey` infers keys from the `features` claim of your claims
  schema.
* `AuthSession`, `AuthState` and `BetterSupabase` take one more type
  parameter, the profile type from the new
  [`betterSupabase.userMetadata(schema)`](/docs/auth#profile). It defaults to `unknown`,
  so existing annotations keep compiling. If you read display fields from
  `session.claims.user_metadata`, move them to the typed `session.profile`.
  It is for display only: users can change it with `auth.updateUser()`, so
  roles, memberships, the tenant and entitlements never come from it.

## Storage and Realtime [#storage-and-realtime]

Bucket and topic policies with `policy: 'tenant'` compare the configured
tenant claim, so they read `tenant_id` after `sql sync` and `gen`. Buckets
and topics can also check permissions: see
[storage](/docs/platform/storage#access-contract-permissions).

## Testing [#testing]

`asUser` signs ES256 tokens with the key `better-supabase keys` writes,
the way a hosted project signs them. Create the key, set `signing_keys_path`
in `supabase/config.toml` and restart the stack ([signing key](/docs/testing#signing-key)).
To keep signing with the shared JWT secret, pass `{ alg: 'HS256' }`; it only
works against a local stack. Tests that used `localAuth(secret)` to accept
test tokens can drop it, since ES256 tokens verify against the stack's JWKS.

## Doctor [#doctor]

* BS405 measures the whole token against 2 KB, and an authorization hook's
  own budget as a separate warning. `doctor.claimsLimit` changes the limit.
* BS407 is new: next to another authorization hook, it reports a hook that
  calls `membership_claims()` or writes the claims that hook owns. The
  `features` claim is not reported.

## MCP [#mcp]

`SPEC_PINS.mcp` and `MCP_PROTOCOL_VERSION` are `2026-07-28`. `createMcp`
serves those stateless requests and still answers `initialize` for
`2025-11-25`, `2025-06-18` and `2025-03-26` clients. A 403 now carries an
`insufficient_scope` challenge; set `scopes` to publish the scopes your tools
need ([MCP servers](/docs/frameworks/mcp)).

# From 0.3 to 0.4

> What to change when upgrading to better-supabase 0.4, which makes requests, generated types and the CLI cheaper.

Source: https://bettersupabase.com/docs/migration/0.3-to-0.4

Most of 0.4 needs no code changes: requests, type checks and CLI runs do less
work. The steps below cover the changes that do. The renames to `bs` and
`betterSupabase` are listed on [Naming](/docs/concepts/naming), and the CLI
now ships inside `better-supabase`, so drop `@better-supabase/cli` from your
dev dependencies.

## Regenerate [#regenerate]

Run `better-supabase gen` and commit what it writes. The schema metadata moves
out of `generated.ts` into two new files next to it, which `generated.ts`
imports:

```txt title="src/lib/supabase"
generated.ts
generated.meta.js
generated.meta.d.ts
```

TypeScript reads the metadata through the declaration file instead of checking
a large object literal, so type checks and the editor use less time and
memory on large schemas. Names in the generated files are now sorted by code
point instead of the machine's locale, so a few entries can move.

`oxfmt` is an optional peer now. Install it to keep `database.types.ts`
formatted; without it, `gen` writes the file unformatted and prints a notice.

## Tokens near expiry [#tokens-near-expiry]

Where the session can't be refreshed (Server Components, route handlers,
prefetches and MCP), a token in its last 60 seconds is now valid until its
`exp`. Before, it resolved as `{ kind: 'anon', reason: 'expired' }`, so pages
rendered signed out in the last minute of every token. `leeway` now only
decides when the proxy refreshes.

A refresh that takes longer than `refreshTimeoutMs` (5000 by default) counts
as a network failure. Raise it in `auth` if your Auth server is slow to
answer.

## A custom `BetterPostgres` [#a-custom-betterpostgres]

If you pass your own `BetterPostgres` to `createServer` instead of the one from
`createPostgres`, implement `executorFor`. `createServer` calls it for
`ctx.sql` and `actingAs()`, so apps that don't use Postgres directly no longer
bundle the SQL compiler:

```ts title="src/lib/postgres.ts"
import {
  postgresExecutor,
  type BetterPostgres,
} from "better-supabase/postgres";

const postgres: BetterPostgres = {
  // ...admin, asUser, anon, transaction and end as before
  executorFor: (claims) => postgresExecutor(postgres.asUser(claims)),
};
```

## Smaller changes [#smaller-changes]

* `parseSnapshot` from `better-supabase/cli` returns a promise. Add `await`.
* `invalidationTargets()` returns a frozen `readonly string[]`. Copy it before
  you change it.
* On the server, `ctx.db` runs on a bare PostgREST client. `ctx.supabase` and
  `ctx.db.$client` still return the full supabase-js client, built when you
  first read them.
* `findMany` without `orderBy` orders by the primary key's database names. On
  a camel-cased table whose key isn't `id`, it used to send the app names,
  which PostgREST rejected.

# From 0.4 to 0.5

> What to change when upgrading to better-supabase 0.5, which renames SQL kit objects to the repo standard and makes the kit fail closed.

Source: https://bettersupabase.com/docs/migration/0.4-to-0.5

0.5 renames the SQL kit's tables and columns to one standard, makes the tenant
checks fail closed and moves the kit's rows out of the schema diff. Most of it
is a migration the CLI writes. The steps below follow the order to run them
in. Modules that are new in 0.5 (`access`, `organizations`, `profiles`,
`outbox`, `webhooks-out`, `notifications`, `support` and `sessions`) need no
upgrade.

## Upgrade the kit files [#upgrade-the-kit-files]

Update the package, then let the CLI write the forward steps and the new
module files:

```bash
better-supabase sql upgrade
better-supabase sql sync
better-supabase sql data
```

`sql upgrade` writes `<timestamp>_better_supabase_kit_upgrade.sql` into the
`migrations` folder next to `config.toml`. Create the schema migration after
it (for example `supabase db diff -f kit_0_5`), so the renames run before the
new definitions. `sql data` is new: it
writes the kit's rows and role settings, which a schema diff can't capture,
into a migration stamped after the newest one. See
[Upgrading modules](/docs/blocks/sql#upgrading-modules).

The forward steps rename these objects:

| Module        | 0.4                         | 0.5                            |
| ------------- | --------------------------- | ------------------------------ |
| `tenant`      | `memberships.org_id`        | `memberships.organization_id`  |
| `invitations` | `invitations.org_id`        | `invitations.organization_id`  |
| `audit`       | `better_supabase.audit_log` | `better_supabase.audit_events` |
| `audit`       | `audit_log.at`              | `audit_events.occurred_at`     |
| `audit`       | `audit_log.org_id`          | `audit_events.organization_id` |
| `audit`       | `audit_trigger()`           | `audit_row_change()`           |

`better_supabase.audit_log` stays as a read-only view with the old column
names until 0.6. A renamed column has no wrapper, so update your own policies,
views and functions that name `org_id` or `at`. Doctor reports them (BS309),
also when the column appears unqualified next to its table.

`audit()`, `purge_audit_log()`, `schedule_job()` and `purge_job_archive()`
gain parameters with defaults, and the forward steps drop the old overloads.
Existing calls keep working; a `grant` or `revoke` that names the old argument
list needs the new one.

## The active tenant [#the-active-tenant]

`current_tenant_id()` used to return the `tenant_id` claim as is. It now
returns a tenant only while the caller is a member of it, and takes it from
the source in `kits.access.activeTenant`, which defaults to `'resolver'`: the
tenant the server resolved for the request (`ServerOptions.tenant`), then the
claim. Apps that only write the claim keep working. To read only the claim,
set it explicitly:

```ts title="better-supabase.config.ts"
export default defineConfig({
  kits: { access: { activeTenant: "claim" } },
});
```

See [The active tenant](/docs/blocks/access#the-active-tenant).

## Entitlements [#entitlements]

The `entitlements` module no longer assumes
`organizations.stripe_customer_id`. With the managed `organizations` module it
reads `better_supabase.organizations.stripe_customer_id`. Otherwise `sql add`
stops until you name the column:

```ts title="better-supabase.config.ts"
export default defineConfig({
  entitlements: { customer: "organizations.stripe_customer_id" },
});
```

## Rate limits and other kit rows [#rate-limits-and-other-kit-rows]

`rate-limit` now sets `pgrst.db_pre_request` for the `authenticator` role in
the migration `sql data` writes, when no other pre-request function is set.
Doctor warns (BS313) when the live database doesn't call `check_request()`.
Schedule the new `purge_rate_limits()` next to the other purges (see
[Retention](/docs/blocks/sql#retention)).

## Behavior that changed [#behavior-that-changed]

* `idempotency` scopes keys to the caller, so two users can no longer replay
  each other's responses.
* `track_realtime` refuses a table without the tenant column. List tables
  that have no tenant in `realtime.global`.
* `jsonb-schemas` adds each check `not valid` and validates it in a second
  statement, so checking existing rows doesn't block writes to the table.
* The rate limit and `request_ip()` use the right-most forwarded hop.
* `jobs` archives a message whose worker died on its last attempt, and dead
  letters get their own retention and `replay_dead_job()`.

## CloudEvents [#cloudevents]

`toCloudEvents` and `kitCloudEvent` no longer set the `actorid` context
attribute, which kept a user id in headers that brokers log. The actor is in
`data.actorId` instead:

```ts
const actor = event.data.actorId; // was event.actorid
```

## New doctor checks [#new-doctor-checks]

BS312 reports a kit schema exposed through the Data API, BS313 rate limits
not wired to PostgREST, and BS314 a migration-only kit option. See
[doctor](/docs/cli/doctor).

# From 0.5 to 0.6

> What to change when upgrading to better-supabase 0.6, which renames kits to blocks, moves the feature modules under better-supabase/blocks, spells out organization and replaces the PermDock settings with authorization providers.

Source: https://bettersupabase.com/docs/migration/0.5-to-0.6

0.6 renames kits to blocks and moves every feature module under
`better-supabase/blocks/<name>`. Names that said `org` now say `organization`,
in TypeScript, in SQL and in event types. The PermDock settings become one
neutral [authorization provider](/docs/extending/authorization-providers).
This release is a clean break: the
old names have no aliases or SQL wrappers, so update imports, config and SQL in
one change.

## Update imports [#update-imports]

| 0.5                                                                        | 0.6                                                               |
| -------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `createOrgs` from `better-supabase/orgs`                                   | `createOrganizations` from `better-supabase/blocks/organizations` |
| `better-supabase/jobs` (queue, cron, idempotency, inbox)                   | `better-supabase/blocks/jobs`                                     |
| `createOutbox`, `outboxCloudEvent` from `better-supabase/jobs`             | `better-supabase/blocks/outbox`                                   |
| `purgeAuditLog`, `PurgeAuditLogOptions` from `better-supabase/jobs`        | `better-supabase/blocks/audit`                                    |
| `createInbox` and the `Inbox*` types from `better-supabase/jobs`           | `createWebhookInbox` and `WebhookInbox*` from `blocks/jobs`       |
| `entitlementMembers`, `ENTITLEMENTS_UPDATED` from `jobs`                   | `better-supabase/blocks/entitlements`                             |
| `hasEntitlement`, `EntitlementKey` from `server`, `next`, `ssr` or `react` | `better-supabase/blocks/entitlements`                             |
| `better-supabase/notifications`                                            | `better-supabase/blocks/notifications`                            |
| `useNotifications` from `better-supabase/react`                            | `better-supabase/blocks/notifications/react`                      |
| `better-supabase/webhooks`                                                 | `better-supabase/blocks/webhooks`                                 |
| `orgLogoBucket`                                                            | `organizationLogoBucket`                                          |

`Org` names become `Organization` names, `Kit` names that describe a feature
become `Block` names, and `Kit` names that describe a SQL module become
`Module` names. The rest of `better-supabase/orgs`, `/notifications` and
`/webhooks` keeps its names under the new subpath (`Invitation`,
`SwitchResult`, `createWebhooks`, `signWebhook` and so on). Every export that
0.6 renames or removes:

| Subpath                             | 0.5.1                                                                       | 0.6                                                                                                                   |
| ----------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `.`                                 | `KitEvent`, `KitEventMap`, `KitEventType`                                   | `BlockEvent`, `BlockEventMap`, `BlockEventType`                                                                       |
| `events`                            | `onKitEvent`, `forwardKitEvents`, `ForwardKitOptions`                       | `onBlockEvent`, `forwardBlockEvents`, `ForwardBlockOptions`                                                           |
| `events`                            | `KIT_ATTRIBUTES`, `kitCloudEvent`, `kitEventAttributes`                     | `BLOCK_ATTRIBUTES`, `blockCloudEvent`, `blockEventAttributes`                                                         |
| `events`                            | `KitEventMeta`, `KitEventPattern`, `KitEventsMatching`                      | `BlockEventMeta`, `BlockEventPattern`, `BlockEventsMatching`                                                          |
| `events`                            | `OrgEventData`                                                              | `OrganizationEventData`                                                                                               |
| `otel`                              | `traceKitEvents`                                                            | `traceBlockEvents`                                                                                                    |
| `orgs`                              | `createOrgs`, `Orgs`, `OrgsOptions`, `CreateOrgOptions`, `OrgAttributes`    | `createOrganizations`, `Organizations`, `OrganizationsOptions`, `CreateOrganizationOptions`, `OrganizationAttributes` |
| `orgs`, `notifications`, `webhooks` | `KitTransport`                                                              | `BlockTransport`                                                                                                      |
| `storage`                           | `orgLogoBucket`, `OrgLogoBucketOptions`                                     | `organizationLogoBucket`, `OrganizationLogoBucketOptions`                                                             |
| `config`                            | `KitsConfig`, `KitModuleConfig`, `KitMode`, `AccessKitConfig`               | `ModulesConfig`, `ModuleConfig`, `ModuleMode`, `AccessModuleConfig`                                                   |
| `sql`                               | `renderKit`, `kitLayout`, `kitPermissionKeys`, `kitDeprecations`            | `renderModules`, `moduleLayout`, `modulePermissionKeys`, `moduleDeprecations`                                         |
| `sql`                               | `kitFilePaths`, `kitFileVersion`, `sameKitFile`                             | `moduleFilePaths`, `moduleFileVersion`, `sameModuleFile`                                                              |
| `sql`                               | `kitIdType`, `isKitIdType`, `KIT_ID_TYPES`, `KitIdType`                     | `moduleIdType`, `isModuleIdType`, `MODULE_ID_TYPES`, `ModuleIdType`                                                   |
| `sql`                               | `KitContext`, `KitLayout`, `KitFile`, `KitNames`, `KitTableSpec`            | `ModuleContext`, `ModuleLayout`, `ModuleFile`, `ModuleNames`, `ModuleTableSpec`                                       |
| `sql`                               | `KitContractFunction`, `KitDeprecation`, `KitPermissionKey`                 | `ModuleContractFunction`, `ModuleDeprecation`, `ModulePermissionKey`                                                  |
| `sql`                               | `KitUpgrade`, `KitUpgradePlan`, `InstalledKitModule`                        | `ModuleUpgrade`, `ModuleUpgradePlan`, `InstalledModule`                                                               |
| `sql`                               | `KitAccessPermdock`, `KitPermdock`                                          | `ModuleAccessProvider`, `ModuleEntitlementsProvider`                                                                  |
| `sql`, `storage`, `realtime`        | `PermdockCatalog`                                                           | `AuthorizationProvider` from `better-supabase/config`                                                                 |
| `storage`, `realtime`               | `PermdockBucketPolicy`, `PermdockTopicPolicy`                               | `AccessBucketPolicy`, `AccessTopicPolicy`                                                                             |
| `config`                            | `PermdockPathsConfig`                                                       | removed; see [Move to an authorization provider](#move-to-an-authorization-provider)                                  |
| `sql`                               | `PERMDOCK_SCHEMA`, `permdockKeys`, `permdockKeyStatus`, `PermdockKeyStatus` | removed; the provider lists its permissions                                                                           |

`list`, `storage`, `realtime` and `events` keep their subpaths.

## Update the config [#update-the-config]

`sql.kit` (the modules to keep in sync) and the `kits` key (their settings)
merge into one key, `sql.modules`. It takes an object keyed by module name,
where every key is a module to keep in sync and its value is that module's
settings. A list of names still works when no module needs settings.

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      access: { roles: ["owner", "admin", "member"] },
      organizations: {},
      invitations: {},
    },
  },
});
```

## Update event types [#update-event-types]

The organization events are now `organization.created`,
`organization.updated`, `organization.deleted`, `organization.role_changed`,
`organization.member_added`, `organization.member_removed`,
`organization.member_left`, `organization.ownership_transferred` and
`organization.switched`. Update `sb.on` patterns, outbox consumers and
webhook subscriptions that name an `org.*` type.

Every event type is now `<entity>.<past_tense_verb>`, with a snake\_case
entity and no third segment. These types were renamed:

| 0.5                                               | 0.6                                               |
| ------------------------------------------------- | ------------------------------------------------- |
| `org.*`                                           | `organization.*`                                  |
| `ai_chat.message.completed`                       | `ai_chat_message.completed`                       |
| `inbox.conversation.*`                            | `inbox_conversation.*`                            |
| `inbox.message.received`                          | `inbox_message.received`                          |
| `incoming_webhook.rotated`                        | `incoming_webhook.token_rotated`                  |
| `workflow.alert`                                  | `workflow_alert.triggered`                        |
| `workflow.run.completed`, `.failed`, `.cancelled` | `workflow_run.completed`, `.failed`, `.cancelled` |
| `data_export.ready`                               | `data_export.completed`                           |

`BLOCK_EVENT_RENAMES` from `better-supabase/events` maps each old
organization type to its new name, for a consumer that reads events written
before the upgrade. The full list of types and their data keys is on the
[events page](/docs/extending/events#block-events).

Payloads and subjects changed too:

* `support.started` and `support.ended` carry a camelCase payload
  (`sessionId`, `adminId`, `targetUserId`, `reason`, `readOnly`,
  `expiresAt`, `organizationId`, and `endedBy` on `support.ended`), and their
  subject is `support-sessions/<id>`.
* Subjects are kebab-case plurals: `push-devices/<id>`,
  `waitlist-entries/<id>` and `audit-entries/<id>`.
* `scim.user_*` and `scim.group_*` name the SCIM resource `scimUserId` or
  `scimGroupId` instead of `id`.
* `support.*`, `notification.created`, `webhook.disabled` and the workflow
  events carry `organizationId`.
* `notification.created`, `webhook.disabled`, `attachment.scanned`,
  `inbox_message.received`, `data_export.completed`, `data_export.failed` and
  `organization.purged` have an idempotency key, so a retried call writes
  one event.

## Regenerate the SQL modules [#regenerate-the-sql-modules]

The module files now start with `-- @bs-module` (`-- @bs-module-data` for the
data files), and the module registry table `better_supabase.kit_modules` is
now `better_supabase.modules`. These SQL functions were renamed:

| 0.5                                            | 0.6                                              |
| ---------------------------------------------- | ------------------------------------------------ |
| `member_org_ids(roles)`                        | `member_organization_ids(roles)`                 |
| `has_org_role(org, roles)`                     | `has_organization_role(organization, roles)`     |
| `org_member_role(org, member)`                 | `organization_member_role(organization, member)` |
| `org` parameters of the organization functions | `organization`                                   |

Update your policies and RPCs that call them, then rewrite the module files and
create the migrations:

```bash
better-supabase sql sync
supabase db schema declarative sync
better-supabase sql data
```

The schema diff drops `kit_modules` and the old functions and creates the new
ones, and `sql data` writes the `modules` rows into a migration after it.
A function whose parameter was renamed can't be replaced in place, so apply
the files through the diff rather than by running them on the database.

## Move to an authorization provider [#move-to-an-authorization-provider]

better-supabase no longer reads `permdock.config.ts`, `permdock.manifest.json`
or `permissions.catalog.json`. The `authorization` key takes a provider
object instead, and PermDock builds it from those files with
`authorizationProvider({ manifest, catalog })` from `permdock/better-supabase`:

```ts title="better-supabase.config.ts"
import { readFileSync } from "node:fs";
import { defineConfig } from "better-supabase/config";
import { authorizationProvider } from "permdock/better-supabase";

const read = (file: string) =>
  readFileSync(new URL(file, import.meta.url), "utf8");

export default defineConfig({
  authorization: authorizationProvider({
    manifest: read("permdock.manifest.json"),
    catalog: read("permissions.catalog.json"),
  }),
  sql: { modules: { access: { model: "provider" } } },
});
```

| 0.5                                                                | 0.6                                                                                                      |
| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- |
| `permdock: { manifest, catalog }` in the config                    | `authorization: authorizationProvider({ manifest, catalog })`                                            |
| `sql.modules.access.model: "permdock"`                             | `sql.modules.access.model: "provider"`                                                                   |
| `sql.modules.access.permdock.schema`, `.scope` and `.forUser`      | the provider's `functions`, `tenantScope` and the `idsWithFor` and `isPlatformFor` templates             |
| `entitlements.permdock: { scope }`                                 | the provider's `tenantScope`                                                                             |
| `entitlements.permdock: false`                                     | `entitlements.memberships: "tenant"`                                                                     |
| `policy: { permdock: { read, write }, scope, schema }` on a bucket | `policy: { access: { read, write }, scope, sql }`, with `bucketPolicy()` from `permdock/better-supabase` |
| `permdock: { receive, send }` on a topic                           | `access: { receive, send, scope, sql }`, with `topicPolicy()` from `permdock/better-supabase`            |
| `catalog` on `defineBucket` and `defineTopic`                      | the provider's `permissions` (`sqlComplete`), checked by `gen` and doctor BS214                          |
| `permdockVerifier` from `better-supabase/blocks/api-keys`          | `apiKeyVerifier` from `permdock/better-supabase`                                                         |
| `permdock` on `apiKeyResolver` and `apiKeyClaims`                  | `claim`, or `apiKeyClaimOptions(manifest)` from `permdock/better-supabase`                               |
| `options.scopes: "catalog"` on the api-keys module                 | unchanged; it reads the provider's `permissions`                                                         |
| `PermdockBucketPolicy`, `PermdockTopicPolicy`, `PermdockCatalog`   | `AccessBucketPolicy`, `AccessTopicPolicy`, `AuthorizationProvider`                                       |
| `KitAccessPermdock`, `KitPermdock`                                 | `ModuleAccessProvider`, `ModuleEntitlementsProvider`                                                     |
| `PERMDOCK_SCHEMA`, `permdockKeys`, `permdockKeyStatus`             | removed; the provider lists its permissions                                                              |

The access policies keep the key lists they had. Without `sql`, they call the
access contract (`tenant_ids_with`, `is_platform`), so a bucket or topic on
the tenant scope works under every access model. Doctor's BS107, BS213,
BS214, BS324, BS404, BS405, BS407 to BS409 and BS411 keep their codes and
read the provider; their titles no longer name PermDock.

Run `better-supabase sql sync` and `better-supabase gen` after the change. The
rendered SQL calls the same functions as before when the provider builds the
same templates.

`better-supabase sql add tenant` refuses to write the module when the
provider's access token hook already writes the memberships claim, since the
two hooks would disagree about it. Use the provider's hook, or pass `--force`
to write the module anyway.

## Removed deprecations [#removed-deprecations]

The aliases that 0.5 kept for one minor are gone:

| Removed                                                    | Use                                                                                                                 |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| A support token whose `act` has `session_id` but no `kind` | tokens minted by 0.5.1 or later (`act.kind: "support"`); older ones are refused as `invalid-chain`                  |
| The read-only `better_supabase.audit_log` view             | `better_supabase.audit_events` (`occurred_at`, `organization_id`); the audit module's version 5 step drops the view |
| `maxUrlLength` on `defineSupabase` and `postgrestExecutor` | `urlLengthLimit`                                                                                                    |
| `scopes` on the MCP server options                         | `advertisedScopes`                                                                                                  |

## Other breaking changes [#other-breaking-changes]

* A bucket definition's `path(values)` and a connected bucket's `publicUrl()`
  return a `Result` instead of throwing a `DbException`. Read `.data` (`null`
  on an error) or check `.ok`.
* `db.$rpc` returns table rows (`returns setof customers`) and
  `returns table (...)` records in the configured casing, with codecs
  applied. Remove a snake-to-camel mapping of the result, or pass
  `{ raw: true }` to keep database names.
* `gen` types function results as nullable: a scalar result, each element of
  a `setof` scalar, a single row and each `returns table` column are
  `| null`. Set `functions.<name>.notNull` in `better-supabase.config.ts`
  when a result is never null.
* `notifications.markRead`, `markUnread`, `dismiss` and `resolve` return
  `{ count, items }` instead of a number. `notifications.send()` returns
  `{ id, recipients }`, or `null` when nobody was left.
* `notifications.list` pages with `cursor` instead of `before`. It takes the
  same values: the last item of the previous page, or an instant.
* The notifications, organizations and outgoing webhooks blocks take
  `mappers` instead of `errorMappers`.
* The leaf entry that runs after `withSupabase` is now
  `withBetterDb(betterSupabase)`. `withBetterSupabase(server)` is one entry
  that resolves the caller itself, so you can drop `withSupabase`.
* The webhook inbox is `createWebhookInbox` from `better-supabase/blocks/jobs`;
  `createInbox` from `better-supabase/blocks/inbox` is the conversation inbox.
* The server's `auth.kind` gains `"apiKey"`, and `DbError` gains the
  kinds `quota_exceeded` (HTTP 429), `max_affected` (400) and `unsupported`
  (501). An exhaustive `switch` over either needs the new cases.
* `deleteMany` and `updateMany` refuse a `where` that filters nothing. Pass
  `allowAll: true` to `updateMany` to update every row. An update whose
  `data` sets no columns returns `invalid_request` instead of `not_found`.
* A `*` in a `like` or `ilike` pattern is a literal character; use `%`.
* List queries count with `count: "planned"` by default. Pass
  `count: "exact"` where you show an exact total.
* Cursors record the table and sort, so a cursor stored before 0.6 or an
  array from `encodeCursor()` fails with `Invalid cursor`. Start again with
  `after: null`.
* `createEdge`'s `cors` option runs `withCors` from
  `@supabase/middleware/cors`: a preflight needs
  `Access-Control-Request-Method`, and an allow-list adds `Vary: Origin`.
* `gen` names colliding `_by_` relations by every key column
  (`customerByCustomerOrganization`) instead of ending them in `_`, and view
  copies are no longer relations. Keep an old name with
  `tables.<table>.relations`.
* `@supabase/postgrest-typegen` is an optional peer. Install it for `gen`,
  `introspect` and `doctor` with
  `pnpm add -D @supabase/postgrest-typegen@0.4.0`. A `GeneratorInput` built
  by hand needs a `model`.
* `Invitation` has a required `inviter` field (`null` without the profiles
  module).
* `better-supabase/next` needs Next.js 16.3 or later (`next` peer
  `>=16.3 <17`). Upgrade Next.js first.
* `better-supabase` depends on `@supabase/server` `^1.9.1`; the framework
  bridges replace its framework adapters.

## SQL behaviour changes [#sql-behaviour-changes]

These apply once `better-supabase sql sync` rewrites the module files:

* A table in `expose` loses privileges it doesn't list, including
  `truncate`, `references` and `trigger`.
* Idempotency keys have a holder: `begin_idempotent` returns `holder`, and
  `complete_idempotent` and `release_idempotent` take it.
* An invitation past its expiry fails with `INVITATION_EXPIRED` (accept) and
  can no longer be edited with `update_invitation`; call
  `resend_invitation` first.
* `roleThrough.where` and `platformRoles.through.where` hold for direct
  writes too, through `bs_role_scope` triggers.
* `mark_notifications_read` and the other notification state functions
  return the changed notifications with the count.
* `inbox` no longer installs `jobs` and `streams`, `workflows` no longer
  installs `jobs`, and `ai-cache` no longer installs `tenant`. Keep them in
  `sql.modules` when you use them: without `jobs`, the inbox queues no bot
  or delivery jobs.
* `webhooks-in` installs `updated-at`.
* `support.ended` carries the session's tenant in its audit entry and outbox
  event.
* Module actions write their audit entries under the shared categories
  (`membership`, `access`, `security`, `configuration`, `billing`, `data`,
  `ai`, `integration`; see [Audit](/docs/blocks/audit#module-actions)).
  Organization entries were `organization`, support entries `support` and
  revealed details `audit`. An adopted log with a category check maps the new
  names with `sql.modules.audit.options.values.category`.
  `options.auditCategory` is deprecated.
* Settings, flags, billing customers, credentials, connectors, agents,
  provider keys, tool policies and approvals, chat shares, incoming webhooks,
  webhook secrets, announcements, published workflows and revealed audit
  details now write an audit entry and an outbox event, as do the access
  catalog's tables (audit entry only). Set `audit: false` on a module to
  leave it out of the log.
* `support.started` carries the session's tenant in its audit entry and
  outbox event.
* `sql.modules.jobs.schema` is an error. Remove it: the jobs functions were
  always in `better_supabase`.

## Run the codemod [#run-the-codemod]

`better-supabase codemod 0.6` renames the `Kit` and `Org` exports from the
rename table where your code imports them, `maxUrlLength` to
`urlLengthLimit`, and string literals that are exactly a renamed event type
(`"org.member_added"`, `"org.*"`) to the new name. It lists the lines it can't rewrite for review:
`$rpc` calls, `publicUrl()` and `path()` calls, and imports from the removed
subpaths. It does not move an import to its new subpath, rewrite SQL or
change config keys. Run it after `better-supabase gen`, then fix what the
type checker reports.

# Existing apps

> Bring better-supabase into an app that already has its own tables, permissions, events and middleware, one module at a time.

Source: https://bettersupabase.com/docs/migration/existing-apps

An app with users in production can adopt better-supabase without renaming a
table, changing a claim or logging anyone out. Each SQL module runs over your
existing tables, the access contract reads your permission model, and the
session cookie is the one `@supabase/ssr` already writes. You move one module
at a time and keep the rest of your code as it is.

## Move the schema into files [#move-the-schema-into-files]

SQL modules are written into `supabase/schemas`, and pg-delta (Declarative
Schemas 2.0) turns those files into migrations. When the app's schema lives
only in migrations or in the dashboard, export it first:

```bash
supabase db pull                                # a baseline migration, when supabase/migrations is empty
supabase db schema declarative generate --linked
```

Then check `supabase/config.toml`:

```toml title="supabase/config.toml"
[experimental.pgdelta]
enabled = true

# only when your migrations call pg_net, for example database webhooks
[experimental.webhooks]
enabled = true
```

Declare every extension your schema files use (`create extension if not
exists pgcrypto;`), including those the local stack already ships, and remove
`[db.migrations] schema_paths`: pg-delta orders the files by dependency.
`supabase db schema declarative sync` should then report no changes, since
the files and the migration history describe the same database.

## Pick a mode per module [#pick-a-mode-per-module]

Every SQL module has a contract: the functions your policies, other
modules and the TypeScript APIs call. `sql.modules.<module>.mode` decides what stands
behind it.

| Mode                | Use it when                                                                     |
| ------------------- | ------------------------------------------------------------------------------- |
| `managed` (default) | you have nothing for this feature yet; the block creates the tables             |
| `adopt`             | you have the tables; the block writes functions over them and never creates one |
| `custom`            | you have the tables and the functions; you write the contract yourself          |

Start with `adopt` for the features you already built (organizations,
invitations, an audit log, an events table) and `managed` for the ones you
don't have. `custom` is for a function you can't replace yet; doctor checks its
signature against the contract (BS307), and `sql print` with the module
name lists the signatures. The
[SQL modules page](/docs/blocks/sql#existing-tables-managed-adopt-and-custom) shows
which modules support which modes.

## Map your names [#map-your-names]

The block never needs your tables to use its names. Map each logical table and
column to yours, and set a column to `null` when you don't have it:

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({
  sql: {
    modules: {
      access: {},
      "webhooks-out": {},
      tenant: {
        mode: "adopt",
        tables: { memberships: "public.organization_users" },
        columns: {
          memberships: {
            tenant: "organization_id",
            role: "role_id",
            lastUsedAt: null,
          },
        },
      },
      organizations: {
        mode: "adopt",
        tables: { organizations: "public.organizations" },
        columns: { organizations: { createdBy: null, deletedAt: null } },
        permissions: { update: "organization.settings.manage" },
        hooks: {
          schema: "public",
          functions: { after_organization_create: "seed_organization" },
        },
        options: { attributes: ["website", "logo_path", "default_currency"] },
      },
      outbox: {
        mode: "adopt",
        tables: { events: "public.workflow_events" },
        columns: {
          events: {
            type: "kind",
            tenant: "organization_id",
            key: "idempotency_key",
            xid: null,
          },
        },
        options: {
          defaultSource: "domain",
          blockSource: "domain",
        },
      },
    },
  },
});
```

`permissions` maps each block action to your permission keys, `hooks` points
the block at functions you already have (seeding a new organization, writing a
contact profile), and `options` covers the rest: extra columns a function
accepts, token storage, slug rules, source values your check constraints
allow. An attribute your input leaves out keeps its column default. An unknown
table or column stops `sql add` and `sql sync` with the valid names, so a typo
fails before it reaches the database.

## Choose an access model [#choose-an-access-model]

Policies and block functions ask one question, `better_supabase.can(scope, id,
permission)`, and [`sql.modules.access.model`](/docs/blocks/access#models) decides
where the answer comes from.

| Your app has                                                         | Model      |
| -------------------------------------------------------------------- | ---------- |
| a role per membership and no permission tables                       | `roles`    |
| roles, permissions and role-permission tables of its own             | `catalog`  |
| an [authorization provider](/docs/extending/authorization-providers) | `provider` |
| something else                                                       | `custom`   |

With `catalog`, map your tables (`roles`, `permissions`, `role_permissions`,
per-tenant overrides) and the columns that disable a tenant or a user. A
helper such as `organization_ids_with_permission(key)` in your policies becomes
`better_supabase.tenant_ids_with(key)`; both return the same set, so you can
switch policy by policy.

The active organization can stay where it is. Set `sql.modules.access.activeTenant`
to `{ profileColumn: "public.profiles.active_organization_id", key: "user_id" }`
when you store it on the profile, or `'resolver'` when the URL decides.

## Keep your events and your receivers [#keep-your-events-and-your-receivers]

An adopted outbox writes to your events table. SQL modules emit in the same
transaction as the change, with the source in `blockSource`, and
`emit_event` calls without a source get `defaultSource`. Your existing
webhook receivers keep their signature format through a
[signer](/docs/blocks/webhooks-out): `hmacSigner` writes `v1=<hex>` over
`timestamp.body` under your header names, so you can move to Standard
Webhooks later, per destination.

## Upgrade without breaking users [#upgrade-without-breaking-users]

* **SQL objects.** Run `better-supabase sql upgrade` after each package
  update. It writes forward steps into a migration before the schema diff runs,
  and `sql upgrade --check` fails CI when a module is behind. A renamed
  function or claim keeps a wrapper under the old name for at least one minor
  release, and doctor reports code that still uses it (BS309).
* **TypeScript.** `better-supabase codemod <version>` rewrites renamed imports
  and members; run it with `--dry-run` first. The
  [stability page](/docs/extending/stability) lists what a minor release may
  change.
* **Triggers.** `track_updated_at()` and `audit()` warn when a table already
  has a trigger doing the same work; pass `replace_trigger => true` to swap it
  in the same call.

## Keep your supabase-js code [#keep-your-supabase-js-code]

The repositories and your hand-written `supabase-js` calls use the same
session and the same Data API, so they work side by side. Move a query when
you touch it, not all at once; the [supabase-js guide](/docs/migration/supabase-js)
maps each call. The raw client stays available as `$client` for anything the
repositories don't cover.

## Keep your sessions [#keep-your-sessions]

better-supabase reads and writes the `sb-<project>-auth-token` cookie in the
`@supabase/ssr` format, chunks included, so a user signed in before the
switch stays signed in after it, and a page still on `@supabase/ssr` reads the
session better-supabase wrote. See [sessions](/docs/auth/sessions).

## Keep your middleware [#keep-your-middleware]

`bs.proxy` owns the session refresh; your other middleware goes in `before`,
and your route rules in `protect`:

```ts title="src/proxy.ts"
import createMiddleware from "next-intl/middleware";
import type { NextRequest } from "next/server";
import { routing } from "@/i18n/routing";
import { bs } from "@/lib/supabase/server";

const intl = createMiddleware(routing);

export const proxy = (request: NextRequest) =>
  bs.proxy(request, {
    before: intl,
    protect: (auth, req) => (auth.kind === "user" ? undefined : toLogin(req)),
  });
```

The refreshed cookie lands on next-intl's rewrite, and the locale header it
sets reaches Server Components. The [middleware page](/docs/auth/middleware#nextjs-proxy-composition)
explains the order.

## A suggested order [#a-suggested-order]

1. Move the schema into `supabase/schemas` on pg-delta, add
   `better-supabase.config.ts` with `access` in the model your app uses, and
   run `better-supabase doctor` to see what it finds.
2. Adopt `tenant` and swap one policy helper for `tenant_ids_with`.
3. Adopt the features you already have (organizations, invitations, audit,
   the events table), one per pull request, and run your tests after each.
4. Add the modules you don't have yet in `managed` mode.
5. Move the session handling to an adapter and `bs.proxy`, then move queries
   to repositories as you touch them.

# Overview

> Guides for upgrading between better-supabase versions and for moving an existing app or library onto better-supabase.

Source: https://bettersupabase.com/docs/migration

Upgrade one minor version at a time: each version guide lists what to
change when moving from the version before it. To bring better-supabase
into an app that already runs, start with the existing-apps guide.

| Page                                                                  | Covers                                                                      |
| --------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| [Existing apps](/docs/migration/existing-apps)                        | Adopting better-supabase one module at a time next to your own tables       |
| [From 0.5 to 0.6](/docs/migration/0.5-to-0.6)                         | Kits renamed to blocks, the `better-supabase/blocks` subpaths and providers |
| [From 0.4 to 0.5](/docs/migration/0.4-to-0.5)                         | SQL object renames and a block that fails closed                            |
| [From 0.3 to 0.4](/docs/migration/0.3-to-0.4)                         | Cheaper requests, generated types and CLI                                   |
| [From 0.1 to 0.2](/docs/migration/0.1-to-0.2)                         | One claim contract for claims and SQL functions                             |
| [From plain supabase-js](/docs/migration/supabase-js)                 | Replacing `supabase.from()` calls with typed repositories                   |
| [From supabase-cache-helpers](/docs/migration/supabase-cache-helpers) | Mapping the cache-helpers React Query hooks to better-supabase              |

# From supabase-cache-helpers

> Map @supabase-cache-helpers/postgrest-react-query hooks to better-supabase.

Source: https://bettersupabase.com/docs/migration/supabase-cache-helpers

supabase-cache-helpers parses your PostgREST query at runtime to find cache
keys and patches cached rows after mutations. better-supabase knows every
table a query touches from the schema instead, and refetches the queries
that overlap a write. There's nothing to parse, and results stay correct
under RLS, includes and cascading deletes. See [Caching](/docs/concepts/caching).

## Setup [#setup]

```ts
import { createQueries, invalidateOnMutation } from "better-supabase/query";

export const q = createQueries(betterSupabase, db);
invalidateOnMutation(betterSupabase, queryClient); // every write through db invalidates
```

## Hooks [#hooks]

| supabase-cache-helpers                                             | better-supabase                                              |
| ------------------------------------------------------------------ | ------------------------------------------------------------ |
| `useQuery(supabase.from('customers').select('id, name'))`          | `useQuery(q.customers.findMany({ select: ['id', 'name'] }))` |
| `.select('*, notes(*)')`                                           | `include: { notes: true }`                                   |
| `.select('..., notes(count)')`                                     | `include: { _count: { notes: true } }`                       |
| `.single()` / `.maybeSingle()`                                     | `findById(id)` / `findUnique({ where })`                     |
| `useInsertMutation(from, ['id'], 'id')`                            | `useMutation(q.customers.create({ select: ['id'] }))`        |
| `useUpdateMutation`                                                | `useMutation(q.customers.update())`                          |
| `useUpsertMutation`                                                | `useMutation(q.customers.upsert())`                          |
| `useDeleteMutation`                                                | `useMutation(q.customers.delete())`                          |
| `useCursorInfiniteScrollQuery`                                     | `useInfiniteQuery(q.customers.infinite({ size, orderBy }))`  |
| `useOffsetInfiniteScrollQuery`, `useInfiniteOffsetPaginationQuery` | `useInfiniteQuery(q.customers.infinitePages({ size }))`      |
| `usePaginationQuery`                                               | `useQuery(q.customers.paginate({ page, size }))`             |
| `useSubscription`, `useSubscriptionQuery`                          | [`useLiveQuery(spec)`](/docs/frontend/live-queries)          |
| `useRevalidateTables([{ table: 'customers' }])`                    | `invalidateTables(queryClient, ['customers'])`               |
| `prefetchQuery(queryClient, query)`                                | `q.$prefetch(queryClient, spec)`                             |
| `fetchQueryInitialData`                                            | `queryClient.fetchQuery(q.$spec(spec))`                      |
| `useQuery(supabase.rpc('fn', args))`                               | `useQuery(q.$rpc('fn', args))`                               |
| Storage `useUpload`, `useFileUrl`, `useDirectory`                  | The [storage block](/docs/platform/storage)'s typed buckets  |

## Differences to expect [#differences-to-expect]

* **Refetch instead of cache patching.** After a mutation the affected
  queries refetch. Use `onMutate` for optimistic UI on the screens that
  need it.
* **Filters are objects.** `.eq('status', 'active')` becomes
  `where: { status: 'active' }`, with types for every column and relation.
* **Errors are typed.** Failed queries reject with `DbException`; use
  [`isConflict`](/docs/repository/unique-and-errors) and friends instead of
  matching error codes.
* **Disabled queries** use TanStack's `skipToken`:
  `q.customers.findById(id ?? skipToken)`.
* **RPCs that write** need `betterSupabase.defineRpc(name, { invalidates })` so their
  mutation invalidates the right tables.

# From plain supabase-js

> Move an app that calls supabase.from() directly to typed repositories, one query at a time.

Source: https://bettersupabase.com/docs/migration/supabase-js

better-supabase wraps the supabase-js client you already have. Nothing about
your database, RLS policies, auth or storage changes, and old
`supabase.from()` calls keep working next to the new repositories, so you
can move one query at a time.

## Set up [#set-up]

### 1. Install and generate [#install-and-generate]

```bash
pnpm add better-supabase
pnpm add -D pg @supabase/postgrest-typegen@0.4.0
pnpm better-supabase init
pnpm better-supabase gen
```

`gen` writes `database.types.ts` with the same output as
`supabase gen types`, so code typed with `createClient<Database>()` keeps
compiling. Point your existing imports at the new file, or keep generating
the old one until you've moved over.

### 2. Wrap the client you already create [#wrap-the-client-you-already-create]

```ts title="src/lib/supabase/index.ts"
import { defineSupabase } from "better-supabase";

import { schema } from "./generated.ts";

export const betterSupabase = defineSupabase(schema);
```

```ts
const supabase = createClient(url, publishableKey); // or createServerClient(...)
const db = betterSupabase.connect(supabase);
```

`betterSupabase.connect()` takes any `SupabaseClient`, including the one from
`@supabase/ssr`. Connect per request with the user's client, as you do
today, so RLS still applies. `db.$client` is the same client, for anything
you haven't moved yet.

### 3. Move queries over [#move-queries-over]

Replace `supabase.from()` calls as you touch them, using the table below.
Once the server side is on repositories, you can replace your hand-written
`@supabase/ssr` setup with an adapter (see [below](#replace-the-ssr-glue)).

## Queries [#queries]

The config's `casing` decides column names. With `casing: 'camel'`,
`organization_id` becomes `organizationId` in `where`, `select` and the rows
you get back. With `casing: 'snake'`, names stay as they are in the database.

| supabase-js                                                             | better-supabase                                                            |
| ----------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `.from('customers').select('id, name')`                                 | `db.customers.findMany({ select: ['id', 'name'] })`                        |
| `.select('*')`                                                          | `findMany()` (every column)                                                |
| `.select('id, organization(name)')`                                     | `select: ['id'], include: { organization: { select: ['name'] } }`          |
| `.select('*, notes(count)')`                                            | `include: { _count: { notes: true } }`                                     |
| `.eq('status', 'active')`                                               | `where: { status: 'active' }`                                              |
| `.neq`, `.gt`, `.gte`, `.lt`, `.lte`                                    | `where: { total: { gte: 100 } }`                                           |
| `.in('status', ['lead', 'active'])`                                     | `where: { status: { in: ['lead', 'active'] } }`                            |
| `.is('deleted_at', null)`                                               | `where: { deletedAt: null }`                                               |
| `.not('email', 'is', null)`                                             | `where: { email: { not: null } }`                                          |
| `.ilike('name', '%acme%')`                                              | `where: { name: { contains: 'acme' } }` (escapes `%` and `_`)              |
| `.or('status.eq.lead,kvk.eq.1001')`                                     | `where: { OR: [{ status: 'lead' }, { kvk: '1001' }] }`                     |
| `.textSearch('document', 'acme')`                                       | `where: { document: { search: 'acme' } }`                                  |
| `.select('*, notes!inner(*)').eq('notes.kind', 'call')`                 | `where: { notes: { some: { kind: 'call' } } }`                             |
| `.order('name', { ascending: false })`                                  | `orderBy: { name: 'desc' }`                                                |
| `.range(20, 29)`                                                        | `limit: 10, offset: 20`, or [`paginate`](/docs/repository/pagination)      |
| `.eq('id', id).single()`                                                | `findById(id)` (a `not_found` error when missing)                          |
| `.eq('slug', slug).maybeSingle()`                                       | `findUnique({ where: { slug } })` (`null` when missing)                    |
| `.eq('status', 'active').maybeSingle()`                                 | `findOnly({ where: { status: 'active' } })` (`multiple_rows` for several)  |
| `.limit(1).maybeSingle()`                                               | `findFirst({ where })`                                                     |
| `.select('*', { count: 'exact', head: true })`                          | `count({ where })`                                                         |
| `.select('*', { count: 'exact' }).range(20, 29)`                        | `paginate({ offset: 20, limit: 10, count: 'exact' })`                      |
| `.insert(row).select().single()`                                        | `create(row)`                                                              |
| `.insert(rows).select()`                                                | `createMany(rows)`                                                         |
| `.update(patch).eq('id', id).select().single()`                         | `update(id, patch)`                                                        |
| `.update(patch).eq('status', 'lead')`                                   | `updateMany({ where: { status: 'lead' }, data: patch })`                   |
| `.update(patch).eq('id', id).eq('organization', organization).select()` | `update(id, patch, { where: { organization } })`                           |
| `.update(patch).in('id', ids).select()`                                 | `updateMany({ where: { id: { in: ids } }, data: patch, returning: true })` |
| `.upsert(row, { onConflict: 'organization_id,kvk' })`                   | `upsert(row, { onConflict: ['organizationId', 'kvk'] })`                   |
| `.delete().eq('id', id)`                                                | `delete(id)`                                                               |
| `supabase.rpc('archive_customer', args)`                                | `db.$rpc('archive_customer', args)`                                        |

Each call is still one PostgREST request, including relation filters and
counts. Every column, relation and operator is typed, and enum columns only
accept their allowed values. See [Filtering](/docs/repository/filtering) and
[Writing](/docs/repository/writing) for the full list.

## Errors [#errors]

supabase-js returns `{ data, error }` with a `PostgrestError`. Repositories
return a `Result` with the same two fields plus `ok`, and a `DbError` whose
`kind` tells you what happened:

```ts
// Before
const { data, error } = await supabase
  .from("tags")
  .insert({ name })
  .select()
  .single();
if (error?.code === "23505")
  return { field: "name", message: "Tag already exists" };
if (error) throw error;

// After
const result = await db.tags.create({ organizationId, name });
if (result.error?.kind === "conflict")
  return { field: "name", message: "Tag already exists" };
if (!result.ok) return result;
const tag = result.data;
```

Use `.orThrow()` where you used to write `if (error) throw error`. The
[Results](/docs/concepts/results) page lists every kind and its HTTP status.

## Replace the SSR glue [#replace-the-ssr-glue]

If you wrote the `@supabase/ssr` cookie handling yourself, an adapter
replaces it. It verifies the token locally, refreshes it only in the proxy,
and gives every handler a `db` for the current user:

| You have                                                        | Use                                                                       |
| --------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `createServerClient` in a Next.js `middleware.ts` or `proxy.ts` | `bs.proxy(request)` ([Next.js](/docs/frameworks/next))                    |
| `createServerClient` in Server Components and route handlers    | `const { db } = await bs.context()`, `bs.route(...)`                      |
| `createBrowserClient`                                           | `createClient(betterSupabase)` ([Browser client](/docs/frontend/client))  |
| A client per request in Hono, oRPC or an Edge Function          | `bs.middleware()` or `bs.handler()` ([Frameworks](/docs/frameworks/hono)) |

Auth flows (`supabase.auth.signInWithOtp()` and friends), storage and
realtime stay on the supabase-js client: `db.$client` on the server,
`bs.supabase` in the browser.

## What to expect [#what-to-expect]

* **Types come from the schema, not the query string.** A typo in a column
  name is a type error, and the row type follows `select` and `include`.
  Rerun `better-supabase gen` after every migration, and add
  `gen --check` to CI.
* **Database errors don't throw.** Code that relied on try and catch
  around a query needs `.orThrow()`.
* **No `declare module` or `Database` generics.** The `schema` object
  carries the types, so there's nothing to pass to `createClient`.
* **PostgREST's limits still apply.** There are no transactions across
  requests; see [Limitations](/docs/guides/limitations) for what to use
  instead.

# Edge Functions

> Typed Edge Function calls, with Standard Schema input and output and a Result on the client.

Source: https://bettersupabase.com/docs/platform/edge-functions

`defineFunction` gives an Edge Function a typed input and output. The function
checks its body with a Standard Schema (zod, valibot, arktype and the rest)
before your handler runs as the caller. Its type carries the contract, so the
app calls it through `functions()` from `better-supabase/client` and gets a
`Result` back instead of an untyped `{ data, error }`.

## Define the function [#define-the-function]

```ts title="supabase/functions/create-customer/handler.ts"
import {
  type ContractOf,
  createEdge,
  defineFunction,
} from "better-supabase/edge";
import * as v from "valibot";
import { betterSupabase } from "../_shared/supabase.ts";

const bs = createEdge(betterSupabase, { cors: true });

export const createCustomer = defineFunction(bs, {
  input: v.object({
    name: v.pipe(v.string(), v.trim(), v.minLength(1)),
    organizationId: v.pipe(v.string(), v.uuid()),
  }),
  output: v.object({
    id: v.string(),
    name: v.string(),
    organizationId: v.string(),
  }),
  handler: (input, { db }) =>
    db.customers.create(input, { select: ["id", "name", "organizationId"] }),
});

export type CreateCustomer = ContractOf<typeof createCustomer>;
```

```ts title="supabase/functions/create-customer/index.ts"
import { createCustomer } from "./handler.ts";

Deno.serve(createCustomer);
```

`defineFunction` returns the same fetch handler as
[`bs.handler`](/docs/frameworks/edge), with these steps in front of your code:

| Step                                | Answer when it fails                            |
| ----------------------------------- | ----------------------------------------------- |
| The guard (`allow`, `aal`, scopes)  | 401 or 403, before the body is read             |
| The body parses as JSON             | 400 `invalid_input`                             |
| `input` accepts the body            | 422 `validation`, with the schema's issues      |
| `output` accepts the handler's data | 500 `unexpected`, so nothing unchecked goes out |

A `GET` or an empty body reaches `input` as `undefined`, so a function that
takes no input uses an optional schema (`v.optional(v.object({}))`). The
handler gets the parsed input and the request context (`db`, `auth`,
`request` and the rest), and returns data, a `Result` or a `Response`, which
is sent as is. `output` is optional; without it, the contract's output is
what the handler returns. The options of `bs.handler` (`allow`, `aal`,
`scopes`) go next to `input`.

## Call it from the app [#call-it-from-the-app]

```ts title="lib/functions.ts"
import type { SupabaseClient } from "@supabase/supabase-js";
import { functions } from "better-supabase/client";
import type { CreateCustomer } from "../supabase/functions/create-customer/handler.ts";

export type Contracts = { "create-customer": CreateCustomer };

export const edgeFunctions = (supabase: SupabaseClient) =>
  functions<Contracts>(supabase);
```

```ts
const fns = edgeFunctions(bs.supabase);

const result = await fns.invoke("create-customer", {
  name: "Acme",
  organizationId,
});
if (!result.ok) return showError(result.error);
result.data.id; // string
```

`invoke(name, input, options?)` sends the input as JSON through
supabase-js's `functions.invoke`, so it carries the session's token, and
returns an `AsyncResult` of the output. `.orThrow()` turns an error into a
`DbException` when you want one. `options` are the `functions.invoke` options
without `body`: `headers`, `method`, `region`, `signal` and `timeout`.

Errors come back as the same `DbError` the server sent:

| What happened                                  | `DbError`                              |
| ---------------------------------------------- | -------------------------------------- |
| The function answered with Problem Details     | its kind, status and fields            |
| The function answered another error            | `unexpected`, with the response status |
| The request never reached the function         | `network`                              |
| The Edge Functions relay failed                | `network`                              |
| `signal` aborted the call, or `timeout` passed | `aborted` or `timeout`                 |

The contract is a type import: nothing from the function's code reaches the
app bundle. The app's TypeScript has to be able to read the function's file,
so keep `Deno` calls in `index.ts` and the definition in a file without
Deno-only imports, as above. When the function's dependencies are not
installed in the app, write the contract by hand with
`FunctionContract<Input, Output>`.

`functions()` takes anything with supabase-js's `functions` client: the
`bs.supabase` of `createClient`, a server's supabase-js client, or a plain
`createClient` from supabase-js.

The [edge example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/edge)
has the function above and its typed caller.

# List queries

> Search, facets, sorting and pagination from one definition, safe from the URL to SQL.

Source: https://bettersupabase.com/docs/platform/list

Most apps have a lot of list pages that each hand-roll the same things: read
`?q=&status=&page=`, validate it, escape the search text, turn "no value" into
`is null`, and document the endpoint. `defineListQuery` does all of that once
per table.

```ts title="lib/lists.ts"
import { defineListQuery } from "better-supabase/list";
import { betterSupabase } from "./supabase";

export const customerList = defineListQuery(betterSupabase, "customers", {
  search: ["name", "kvk"],
  facets: { status: "status", kvk: "kvk" },
  sorts: {
    name: [{ name: "asc" }, { id: "asc" }],
    newest: [{ createdAt: "desc" }, { id: "asc" }],
  },
  defaultSort: "newest",
  pageSize: 25, // default 50, capped by maxPageSize (default 200)
});
```

Column names, sort keys and facet keys are all typed. `search` only accepts
text columns. `defaultSort` must be one of the `sorts`.

## Parse and run [#parse-and-run]

`parse` accepts `URLSearchParams`, a Next.js `searchParams` record, or typed
input. Facet values can be comma-separated or repeated
(`?status=active,lead` or `?status=active&status=lead`).

```ts title="app/customers/page.tsx"
export default async function Page({ searchParams }: PageProps<'/customers'>) {
  const { db } = await bs.context();
  const query = customerList.parse(await searchParams);
  if (!query.ok) return <InvalidFilters issues={query.issues} />;

  const page = await customerList
    .run(db, query.value, { select: ['id', 'name', 'status'], where: { archivedAt: null } })
    .orThrow();
  // page.items: { id: string; name: string; status: CustomerStatus }[]
}
```

`run` calls `paginate` and ANDs your own `where` with the list filters. Plugins
such as tenant scoping and soft delete still apply. `include` takes the same
relation map as `findMany`. If you only want the arguments, use
`customerList.args(query)`.

The total uses `count: 'planned'` by default: the planner's row estimate,
which costs nothing extra but can be off after bulk changes until the table is
analyzed. Set `count: 'exact'` in the config when the UI needs the precise
total (a "page 3 of 7" footer on a small table), `'estimated'` for exact
counts below PostgREST's `db-max-rows` and the estimate above, or pass `count`
to one `run`; see [Pagination](/docs/repository/pagination) for what each mode costs.

## Cursor pagination [#cursor-pagination]

Set `pagination: 'cursor'` and the list takes `after` instead of `page`:

```ts
export const customerFeed = defineListQuery(betterSupabase, "customers", {
  sorts: { newest: [{ createdAt: "desc" }, { id: "desc" }] },
  defaultSort: "newest",
  pagination: "cursor",
});

const page = await customerFeed.run(db, query).orThrow();
// page: { items, nextCursor, hasMore }
customerFeed.toSearchParams({ ...query, after: page.nextCursor! });
```

A cursor page skips the count and reads the same number of rows however
deep it is (see [Pagination](/docs/repository/pagination#cursors)). `page`
in the input is rejected with an issue, as is `after` on an offset list.
`size` is still capped by `maxPageSize`, and the OpenAPI parameters and JSON
Schema describe `after`.

## Facet counts [#facet-counts]

Filter UIs usually show how many rows each facet value would match
("Active (12)"). Set `facetCounts: true` and `run` also returns
`page.facetCounts`:

```ts
export const customerList = defineListQuery(betterSupabase, "customers", {
  facets: { status: "status", kvk: "kvk" },
  sorts: { name: { name: "asc" } },
  defaultSort: "name",
  facetCounts: true,
});

const page = await customerList.run(db, query).orThrow();
page.facetCounts.status; // { active: 12, lead: 4, archived: 0 }
page.facetCounts.kvk; // { '1001': 3, __unset__: 13 }
```

Each facet gets its own grouped aggregate, filtered by the search, your own
`where` and every other facet's selection but not its own, so picking `active`
doesn't turn `lead` into 0. Enum and CHECK values with no rows show up as 0.
Empty values are keyed `UNSET`.

Each aggregate counts at most `facetLimit` values (100 by default), the most
frequent first, with ties in column order. A facet with more values keeps the
`facetLimit` values that match the most rows, and its key is listed in
`page.facetCountsTruncated`, so the UI can say the list is cut:

```ts
page.facetCountsTruncated; // [] when every facet was counted in full
```

This needs PostgREST aggregates (`pgrst.db_aggregates_enabled`); doctor warns
with [BS210](/docs/cli/doctor) when they are off. The counts
[sort by `_count`](/docs/repository/aggregates#sort-groups-by-an-aggregate),
which PostgREST can't do on a table with a column named `count`. If an
aggregate fails, the whole `run` fails with its `DbError`.

## Request budget [#request-budget]

A list is one request, plus one per facet when `facetCounts` is on, and always
one wave: the page and the aggregates run in parallel. Assert it with
[`db.$stats()`](/docs/repository):

```ts
await customerList.run(db, query);
expect(db.$stats()).toMatchObject({ calls: 3, waves: 1 });
```

## What it compiles to [#what-it-compiles-to]

| Input                | Filter                                      |
| -------------------- | ------------------------------------------- |
| `q=road`             | `name ilike '%road%' or kvk ilike '%road%'` |
| `status=active,lead` | `status in ('active', 'lead')`              |
| `kvk=__unset__`      | `kvk is null`                               |
| `kvk=__unset__,1001` | `kvk in ('1001') or kvk is null`            |

Search text is trimmed, capped at `maxSearchLength` (200) and escaped: `%`,
`_` and `\` match literally, and commas, parentheses and quotes can't break out
of a PostgREST `or=(...)` filter. The same query gives identical results
through PostgREST and the direct [Postgres executor](/docs/auth/postgres).

For full-text search, point `search` at a `tsvector` or text column:

```ts
search: { fts: 'searchVector', config: 'dutch' } // websearch_to_tsquery
```

## Validation [#validation]

Facet values are checked against enum and CHECK-constraint values from the
generated schema. `__unset__` (exported as `UNSET`) is only allowed on
nullable columns. Every problem is reported with a path:

```ts
customerList.parse(new URLSearchParams("sort=oldest&page=0&status=bogus"))
  .issues;
// [{ path: ['sort'], ... }, { path: ['page'], ... }, { path: ['facets', 'status'], ... }]
```

`customerList.schema` is a [Standard Schema](/docs/standards), so it plugs into
any router or form library that accepts one. Pass it to `bs.action({ input })`
or an oRPC procedure without writing a validator.

## URLs and nuqs [#urls-and-nuqs]

`toSearchParams` writes a query back to the URL, leaving out defaults, so
links stay short and round-trip exactly:

```ts
customerList.toSearchParams({ ...query, page: 2 }).toString(); // 'q=road&page=2'
```

For client-side state with [nuqs](https://nuqs.dev), pass its `createParser`.
better-supabase does not depend on nuqs:

```ts
"use client";
import { createParser, useQueryStates } from "nuqs";

const parsers = customerList.nuqs(createParser);
const [state, setState] = useQueryStates(parsers);
```

`customerList.parsers` exposes the same `{ parse, serialize }` pairs for
other routers.

## OpenAPI, MCP and filter UIs [#openapi-mcp-and-filter-uis]

The definition describes itself:

* `customerList.openapi`: OpenAPI 3.1 query parameters, with facets as
  `style: form, explode: false` string arrays that list their enum values.
* `customerList.jsonSchema`: JSON Schema for the typed input. Use it for MCP
  tool arguments or JSON APIs.
* `customerList.facets`: `{ key, column, nullable, values? }` for rendering
  filter menus.

# Supabase Lite

> Run better-supabase on Supabase Lite, generate types from a Lite project and test against it in memory.

Source: https://bettersupabase.com/docs/platform/lite

[Supabase Lite](https://github.com/supabase/lite) (`@supabase/lite`) serves
the Supabase API (PostgREST, Auth and Storage) from one process, on SQLite,
PGlite or Postgres. better-supabase talks to it through supabase-js like any
other project, so the repository, auth and blocks work unchanged. Three things
differ: Lite signs tokens with a shared HS256 secret, some features are
missing on some drivers, and `gen` reads the schema without a running
database.

```bash
pnpm better-supabase init --lite
```

[`init --lite`](/docs/cli/init#supabase-lite) prints the steps below for
your package manager.

## Server [#server]

Lite signs its access tokens with `[auth] jwt_secret` and no key id, and it
publishes no JWKS. Pass `backend: 'lite'` and give the server the same secret
in `SUPABASE_JWT_SECRET`, so it verifies tokens locally like it does with
asymmetric keys on hosted projects:

```toml title="supabase/config.toml"
[auth]
enabled = true
jwt_secret = "env(SUPABASE_JWT_SECRET)"
publishable_key = "env(SUPABASE_PUBLISHABLE_KEY)"
secret_key = "env(SUPABASE_SECRET_KEY)"
```

```bash title=".env"
SUPABASE_URL=http://127.0.0.1:54321
SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
SUPABASE_SECRET_KEY=sb_secret_...
SUPABASE_JWT_SECRET=a-secret-of-at-least-32-characters
```

```ts title="src/lib/supabase/server.ts"
import { createServer } from "better-supabase/server";

import { betterSupabase } from "./index";

export const bs = createServer(betterSupabase, {
  backend: "lite",
  liteDriver: "sqlite-postgres",
});
```

`createNext`, `createHono`, `createEdge` and `createOrpc` take the same
options. With `backend: 'lite'`:

* the server verifies HS256 tokens without a `kid` against
  `SUPABASE_JWT_SECRET`, and still verifies tokens that carry a `kid` through
  the JWKS. It throws at startup when the secret is missing;
* `env` rejects a `SUPABASE_JWT_SECRET` shorter than 32 characters. Lite's
  scaffolded `dev-secret-change-me` is too short, and `lite upgrade` also
  refuses it without `--allow-weak-jwt-secret`;
* it skips the JWKS prefetch, since Lite has no JWKS endpoint;
* `liteDriver` (default `sqlite-postgres`) tells the server which calls Lite
  can't serve. On `sqlite-postgres` and `sqlite`, `$rpc` returns an
  `unsupported` error (code `RPC_UNAVAILABLE`, status 501) without sending a
  request;
* `postgres` (`ctx.postgres`) needs the `postgres` driver, since the other
  drivers have no Postgres connection; `createServer` throws otherwise.

## What works on each driver [#what-works-on-each-driver]

| Feature                        | `sqlite-postgres`, `sqlite` | `pglite`, `postgres` |
| ------------------------------ | --------------------------- | -------------------- |
| Repository and `$from` queries | yes                         | yes                  |
| RLS with `auth.uid()`          | yes                         | yes                  |
| `$rpc` and function hooks      | no                          | yes                  |
| `DEFAULT auth.uid()`           | no                          | yes                  |
| Storage                        | yes (experimental)          | yes (experimental)   |
| Realtime, Edge Functions       | no                          | no                   |

Lite's own [LIMITATIONS.md](https://github.com/supabase/lite/blob/HEAD/LIMITATIONS.md)
lists the SQL its SQLite translation rejects, such as a subquery in an
insert's `with check`. [Doctor](/docs/cli/doctor#bs328) reports `$rpc` calls
on a SQLite driver (BS328) and Realtime or Edge Functions use on any driver
(BS329).

## Generating types [#generating-types]

`gen` and `doctor` find the `[db] driver` that `lite init` writes to
`supabase/config.toml`:

| Driver                      | Where `gen` reads the schema                                                                      |
| --------------------------- | ------------------------------------------------------------------------------------------------- |
| `postgres`                  | the `[db] url`                                                                                    |
| `pglite`, `sqlite-postgres` | `supabase/migrations`, then the declarative schema files, replayed into an in-memory PGlite       |
| `sqlite`                    | nothing: native SQLite DDL has no Postgres types. Switch to `sqlite-postgres`, or save a snapshot |

The replay needs `@supabase/lite` installed and no running server. Doctor
skips the Supabase advisors and the statistics checks on a replay, because
an in-memory PGlite has no Supabase roles and no workload. Set
`source.lite: false` to read the database the usual way instead. See
[Where the schema comes from](/docs/cli/gen#where-the-schema-comes-from).

## Testing [#testing]

`liteStack()` from `better-supabase/testing` starts Lite inside the test
process, on in-memory SQLite (the `sqlite-postgres` driver) or PGlite. It
applies your migrations and schema, runs the seed, and answers requests
through `app.fetch`, so no port is opened and no Docker is needed:

```ts title="tests/notes.test.ts"
import { readFile } from "node:fs/promises";
import { createServer } from "better-supabase/server";
import { liteStack, signTestJwt } from "better-supabase/testing";
import { afterAll, expect, it } from "vitest";

import { betterSupabase } from "../src/lib/supabase";

const stack = await liteStack({
  driver: "pglite",
  schemas: [await readFile("supabase/schemas/notes.sql", "utf8")],
  seed: "insert into public.notes (body) values ('hello');",
});
afterAll(() => stack.close());

it("reads notes as a user", async () => {
  const bs = await stack.asUser(betterSupabase, { sub: crypto.randomUUID() });
  const notes = await bs.notes.findMany();
  expect(notes.ok).toBe(true);
});

it("verifies Lite tokens on the server", async () => {
  const bs = createServer(betterSupabase, {
    backend: "lite",
    liteDriver: "pglite",
    env: stack.env,
    fetch: stack.fetch,
  });
  const token = await signTestJwt(stack.jwtSecret, {
    sub: crypto.randomUUID(),
  });
  const ctx = await bs.context(
    new Request("https://app.test/", {
      headers: { authorization: `Bearer ${token}` },
    }),
  );
  expect(ctx.auth.kind).toBe("user");
});
```

| Option       | Default           | What it does                                                   |
| ------------ | ----------------- | -------------------------------------------------------------- |
| `driver`     | `sqlite-postgres` | `sqlite-postgres` (SQLite in memory) or `pglite`               |
| `migrations` | none              | SQL applied first, in order, through Lite's migrator           |
| `schemas`    | none              | declarative schema SQL applied after the migrations            |
| `seed`       | none              | SQL run after the schema, translated to SQLite when needed     |
| `jwtSecret`  | random            | the secret Lite signs with and `asUser` signs test tokens with |

The stack exposes `url`, `publishableKey`, `secretKey`, `jwtSecret`, `env`
(ready for `createServer`), `fetch`, the `supabase` and `admin` clients,
`asUser()` and `close()`. It also works with `await using`. See
[Testing](/docs/testing) for the Docker-based `localStack`.

## Moving to Supabase [#moving-to-supabase]

`lite upgrade` replays a Lite project's migrations into a hosted or local
Supabase project and moves user data and sessions. Afterwards, remove
`backend: 'lite'` and `SUPABASE_JWT_SECRET`: Supabase signs with asymmetric
keys that the server verifies through the JWKS.

# Realtime

> Typed broadcast topics with generated authorization, row-change triggers and disposable subscriptions.

Source: https://bettersupabase.com/docs/platform/realtime

`defineTopic` describes a private Realtime broadcast topic once. The same
definition names the channel, authorizes it in SQL, validates payloads, and
subscribes.

```ts title="lib/topics.ts"
import { defineTopic } from "better-supabase/realtime";
import * as v from "valibot";

export const customersTopic = defineTopic(
  "organization:{organizationId}:customers",
);

export const notifications = defineTopic(
  "organization:{organizationId}:notifications:{userId}",
  {
    events: { created: v.object({ id: v.string(), title: v.string() }) },
    send: true,
  },
);
```

Or declare templates under `topics` in `better-supabase.config.ts` and pass
the generated constant: `defineTopic(topics.notifications)`.

## Authorization [#authorization]

Topics are private by default: Realtime checks `realtime.messages` policies
when a client joins. `topic.sql()` generates them:

* The topic must match the template (`^organization:[^:]+:notifications:[^:]+$`).
* `{organizationId}` must equal the JWT `tenant_id` (or `app_metadata.tenant_id`).
* `{userId}` must equal `auth.uid()`.

Both checks switch on automatically when the template has those placeholders.
Configure them with `tenant: { param, claim | sql }` and `owner: { param }`,
or turn them off with `false`. `send: true` adds an insert policy so clients
can broadcast too, and `presence` authorizes [presence](#presence).

`access` replaces the tenant check with permissions: joining needs `receive`
in the scope named by the `{organizationId}` segment, and sending needs
`send`. Without `sql`, the policies call the [access contract](/docs/blocks/access)
(`tenant_ids_with`, or `is_platform` for `scope: 'platform'`). With an
[authorization provider](/docs/extending/authorization-providers),
`sql: "provider"` calls its `idsWith` and `isPlatform` functions for any
scope:

```ts
export const board = defineTopic("organization:{organizationId}:board", {
  access: {
    receive: "board.read",
    send: "board.write",
    scope: "organization",
    sql: "provider",
  },
});
```

`better-supabase sql sync` fills in the config's `authorization.functions`.
Anywhere else, pass them to `topic.sql({ functions })`; without them it
throws. An object with `idsWith` and `isPlatform` copies the templates
instead.

Without `send`, clients can only receive. `segment` (1-based, `:`-separated)
works as for [buckets](/docs/platform/storage#provider-permissions). The
segment is compared as text, so it must be the id's canonical lowercase form:
a topic with an uppercase uuid is denied.

The policies check role and scope, not a permission's row conditions. Use a
key only when its grants have no row conditions beyond the scope: for one
that has them, the topic lets every member of the scope join or send. Keep
those permissions on tables.

```ts
// Wrong: board.read is granted only for boards the member owns
access: { receive: "board.read", scope: "organization", sql },
// Right: board.view is granted per organization, with no row condition
access: { receive: "board.view", scope: "organization", sql },
```

`better-supabase gen` and [`doctor`](/docs/cli/doctor#bs214) (BS214) refuse a
`receive` or `send` key the provider doesn't mark `sqlComplete: true`, for
topics in the config and for generated topic policies in your SQL files.

### Writing the policies to a file [#writing-the-policies-to-a-file]

`realtime.policies` keeps the generated policies in a schema file:
`better-supabase sql sync` imports the modules in `from`, collects every
topic they export, and writes their `sql()` to `output`, and
`sql sync --check` fails when the file no longer matches, for example in CI:

```ts title="better-supabase.config.ts"
export default defineConfig({
  realtime: {
    policies: {
      from: ["src/realtime/topics.ts"],
      output: "supabase/schemas/905_topics.sql",
    },
  },
});
```

Node imports the modules, so their relative imports need the `.ts`
extension. Create a migration from the file as from any other schema file.

Some SQL modules write the receive policy of their own topics: `notifications`
(its `topic` option, `notifications:{userId}` by default, while `realtime` is
`broadcast`), `announcements` (its `topic` option, `announcements` by
default) and `realtime-tables` (every topic that starts with `bs:t:`). When a
module in `sql.modules` covers a topic you export, for example to subscribe
to it with the typed client, `sql sync` leaves that topic out of `output` and
names the module in a comment at the top of the file, so the database doesn't
get two equivalent policies. Templates match by their literal parts, so
`notifications:{user}` matches `notifications:{userId}`. A topic that also
sends or uses presence keeps its policies, because the module only writes a
receive policy for broadcasts.

## Before production [#before-production]

* **Turn off public access.** The policies only apply to private channels. In
  the dashboard's Realtime settings, turn off "Allow public access" so a
  client can't join the same topic as a public channel and skip them.
* **Policies are cached per connection.** Realtime checks them when a client
  joins and keeps the result until the client's access token refreshes or it
  rejoins. A member you remove keeps receiving until then, up to the token's
  expiry.
* **`realtime.messages` is not a history.** Database broadcasts are rows in
  `realtime.messages`, which is partitioned by day and pruned after a few
  days. Clients that reconnect should refetch from your tables.

## Row changes [#row-changes]

`triggerSql` writes a trigger that sends row changes with
`realtime.broadcast_changes`. Placeholders map to columns by their app names:

```ts
customersTopic.triggerSql(betterSupabase, "customers", {
  values: { organizationId: "organizationId" },
});
```

The trigger function goes into the `better_supabase` schema, which the Data
API does not expose, so clients can't call it through `/rpc`. Pass
`functionSchema` to put it somewhere else.

A placeholder can also come from a parent row. A lookup `{ from, via,
select }` reads the `select` column of the `from` row whose primary key
(or `key`) equals `via`, a column of the changed row or another lookup, so a
delivery can reach its thread's topic through the message it belongs to.
A row whose topic comes out null, such as one whose parent is gone, sends
nothing.

```ts
threadTopic.triggerSql(betterSupabase, "deliveries", {
  values: {
    threadId: { from: "messages", via: "messageId", select: "threadId" },
  },
  event: { insert: "delivery_added", update: "delivery_changed" },
  payload: {
    deliveryId: "id",
    organizationId: {
      from: "threads",
      select: "organizationId",
      via: { from: "messages", via: "messageId", select: "threadId" },
    },
    operation: { sql: "lower(tg_op)" },
  },
});
```

`event` names the broadcast event, for every operation or per operation
(`INSERT`, `UPDATE` or `DELETE` otherwise). With `payload` the trigger
sends that object with `realtime.send` instead of the row change: each key is
a column, a lookup, or `{ sql }`, an expression on the row `{row}` with
`tg_op` for the operation. `rowChange` reads only the row-change shape, so
handle a custom payload with its own event schema.

On the client, `rowChange` turns a message into an app-cased change, or `null`
for other tables:

```ts
const change = rowChange(betterSupabase, "customers", message);
// { operation: 'UPDATE', table: 'customers', record: Customer | null, oldRecord: Partial<Customer> | null }
```

## Subscribe [#subscribe]

```ts
using sub = notifications.subscribe(
  supabase,
  { organizationId, userId },
  {
    created: (n) => toast(n.title), // typed from the schema
    "*": (payload, message) => console.log(message.event, payload),
  },
);
await sub.ready; // rejects when the join is refused
```

`using` ends the subscription at the end of the scope. `await using` waits for
it, and you can also call `sub.unsubscribe()`. Subscriptions to the same topic
on one client share a channel, which is removed when the last one ends; they
must agree on `self`. A channel whose first join is refused or times out is
dropped, so the next `subscribe` joins again. For private topics,
`subscribe` calls `realtime.setAuth()` first, so Realtime gets the
session's current token. A token you set yourself with
`supabase.realtime.setAuth(token)`, such as one minted for an agent or a
service, is kept: `subscribe`, `send`, `useBroadcast`, `usePresence` and `useLiveCount` only
refresh a token that came from the session. Payloads that fail their schema
skip the handler and go to `onInvalid`.

## Send [#send]

```ts
await notifications
  .send(supabase, { organizationId, userId }, "created", { id, title })
  .orThrow();
```

The payload is validated first (a `validation` error on failure) and sent over
HTTP without joining the channel. Policy denials come back as `unauthorized`
or `forbidden`.

## Presence [#presence]

Pass a schema as `presence` to show who is on a topic. The policies then
authorize presence, and every subscription can `track` this client's state
and get everyone's states after each sync:

```ts title="lib/topics.ts"
export const customerViewers = defineTopic(
  "organization:{organizationId}:customer:{customerId}:viewers",
  { presence: v.object({ userId: v.string(), name: v.string() }) },
);
```

```tsx title="features/customers/components/viewers.tsx"
"use client";

export function Viewers({ organizationId, customerId, me }: Props) {
  const supabase = useSupabase();
  const [viewers, setViewers] = useState<readonly string[]>([]);

  useEffect(() => {
    const sub = customerViewers.subscribe(
      supabase,
      { organizationId, customerId },
      {},
      {
        onPresence: (members) =>
          setViewers([...new Set(members.map((m) => m.state.name))]),
      },
    );
    void sub.track({ userId: me.id, name: me.name });
    return () => void sub.unsubscribe();
  }, [supabase, organizationId, customerId, me.id, me.name]);

  return <AvatarStack names={viewers} />;
}
```

`track` validates the state (a `validation` error on failure), waits for the
join and returns a `Result`. States other clients track that fail the schema
are left out of `onPresence` and reach `onInvalid` as a `presence` message.
`members()` returns the last sync. Each tab is its own member with its own
`key`, so group by a field of the state (`userId` above) to count people.

Presence shares the topic's channel, so one client has one state per topic:
the last `track` wins, and it goes away with the subscription that tracked
it. `presence: true` skips the schema and accepts any object. Every
definition of a topic name must agree on presence. For cursors or
collaborative editing, broadcast positions as a regular event; presence
suits state that changes a few times a session, since every change reaches
everyone.

## Chat and cursors [#chat-and-cursors]

The realtime chat and cursor blocks in the Supabase library join a public
channel, take the sender's name from the client, and keep messages only in
the browsers that were open when they arrived. To build the same features on
topics:

* **Authorize the topic.** Use a private `defineTopic` template with a
  tenant or owner placeholder, and turn off public access (see
  [Before production](#before-production)).
* **Persist messages.** Insert each message into a table and broadcast it
  with [`triggerSql`](#row-changes), so the row is the record. Clients that
  join late or reconnect load the history from the table, and the sender's
  id comes from `auth.uid()` in the row's policy instead of from the client.
* **Throttle sends in the app.** Realtime limits the messages per second for
  each client and project. Send a cursor position at most every 50 to 100 ms
  and drop the positions in between; `send` and `track` don't throttle for
  you.
* **Name people from the session.** Use the user id as the identity, and
  take names and colors from a profile row or the presence state.

The collaborative editor blocks sync Yjs documents through
`@supabase-labs/y-supabase`. better-supabase ships no Yjs provider, so that
stays an app dependency; check that it joins a private channel before you
rely on a topic policy to guard the document.

## React [#react]

```tsx
import { useBroadcast } from "better-supabase/react";

const status = useBroadcast(
  customersTopic,
  organizationId ? { organizationId } : null,
  {},
  { invalidate: ["customers"] },
);
```

The hook subscribes while mounted and resubscribes when the topic or the
signed-in user changes. `invalidate` refetches the table's
[TanStack queries](/docs/frontend/query) after each message. It needs
`queryClient` on `BetterSupabaseProvider`. The hook returns `joining`,
`subscribed`, `closed` or `error`.

An app that uses supabase-js without the better-supabase client passes its
client (and a `queryClient` for `invalidate`) instead of the provider. The
hook then follows that client's auth state to resubscribe for a new user:

```tsx
const status = useBroadcast(
  customersTopic,
  { organizationId },
  { updated: (payload) => refresh(payload.id) },
  { client: supabase, queryClient },
);
```

# Storage

> Typed bucket paths, generated policies, and the upload flows apps keep rewriting.

Source: https://bettersupabase.com/docs/platform/storage

`defineBucket` turns a path template into typed path builders, bucket limits,
`storage.objects` policies and a `config.toml` section. Its client wraps
supabase-js Storage calls in the same `Result` as the repository.

```ts title="lib/buckets.ts"
import { defineBucket } from "better-supabase/storage";

export const logos = defineBucket({
  id: "customer-logos",
  path: "{organizationId}/{customerId}/logo/{version}.webp",
  policy: "tenant",
  fileSizeLimit: "5MiB",
  allowedMimeTypes: ["image/png", "image/jpeg", "image/webp"],
});
```

Or declare it in `better-supabase.config.ts` under `buckets` and use the
generated constant: `defineBucket(buckets.customerLogos)`. The path values
stay typed either way.

## Paths [#paths]

```ts
logos.path({ organizationId, customerId, version: 3 }); // { ok: true, data: 'o1/c1/logo/3.webp' }
logos.match("o1/c1/logo/3.webp"); // { organizationId: 'o1', customerId: 'c1', version: '3' }
logos.prefix({ organizationId }); // 'o1', the folder to list
```

Missing values are type errors. At runtime every value is checked: it can't
be empty, `.` or `..`, contain `/`, or use characters Storage rejects. A
failed check is an `invalid_input` error, and no request is sent. `path()`
returns a `Result` like every other bucket method, so a value from user input
never throws:

```ts
const path = logos.path({ organizationId, customerId, version });
if (!path.ok) return problemResponse(path.error); // invalid_input, 400
```

Client methods accept values or an existing path string, and a path string
must match the template.

### Several layouts [#several-layouts]

A bucket often holds objects in more than one shape: file versions,
exports and attachments side by side, and paths written by an older layout
that rows still reference. Pass `path` an array of templates. A path from
values uses the template whose placeholders are exactly the keys you give,
and a stored path string is accepted when any template matches it, so put
the current layout first and keep older ones after it:

```ts title="lib/buckets.ts"
export const files = defineBucket({
  id: "files",
  path: [
    "{organizationId}/files/{fileId}/v{version}.{ext}",
    "{organizationId}/exports/{exportId}.zip",
    "{organizationId}/{...rest}",
  ],
  policy: "tenant",
  tenant: {},
});

files.path({ organizationId, exportId }).data; // 'o1/exports/e1.zip'
files.match("o1/2023/report.pdf"); // { organizationId: 'o1', rest: '2023/report.pdf' }
```

A last segment written as `{...rest}` matches one or more segments, for
stored paths whose shape varies. Each of its segments is checked like any
other value: no empty segments, no `.` or `..`, and only characters Storage
accepts. A path with a leading `/` or outside the templates is refused.

Every template must hold the tenant placeholder, and a `tenant`, `owner` or
permission policy needs its placeholder at the same segment in every
template, so the generated policies and the connected client check one
segment for all of them. The write policies accept a name that matches any
template. `prefix(values)` returns the folder that every template holding
those values shares.

### Path columns [#path-columns]

`logos.path()` (inside its `Result`) and `upload()` return a
`StoragePath<'customer-logos'>`: a string branded with the bucket id. Client methods take plain strings too, but
reject a path branded for another bucket.

Store that path in the row, never a URL. Signed URLs expire and public URLs
pin the project host, so build the URL when you render. List the column in
`storagePaths` and `gen` types it:

```ts title="better-supabase.config.ts"
export default defineConfig({
  buckets: {
    customerLogos: {
      path: "{organizationId}/{customerId}/logo/{version}.webp",
      policy: "tenant",
    },
  },
  storagePaths: { "customers.logo_path": "customerLogos" }, // a buckets key or a bucket id
});
```

```ts
customer.logoPath; // StoragePath<'customer-logos'> | null
await db.customers.update(id, { logoPath: "o1/c1/logo/3.webp" }); // type error: not a path of that bucket
await db.customers.update(id, {
  logoPath: (await storage.upload(values, file).orThrow()).path,
}); // ok
```

`gen` fails on a key that names no column or a column that isn't text. The
[`storagePathColumns` rule](/docs/plugins/rules) warns when a Storage URL or
bucket path is written to a `*_url` column.

### Rows that store a bucket id [#rows-that-store-a-bucket-id]

Tables for files, attachments or knowledge sources often keep the bucket id
next to the path, because their objects live in more than one bucket.
`defineBuckets` registers the bucket definitions by id, so a stored
`{ bucket, path }` reaches its definition without a map of your own:

```ts title="lib/buckets.ts"
import { defineBuckets } from "better-supabase/storage";

export const buckets = defineBuckets({ files, attachments, sources });
```

```ts
const ref = buckets.connectStored(
  supabase,
  { bucket: row.bucketId, path: row.path },
  { context: db.$context },
);
if (!ref.ok) return problemResponse(ref.error);
const url = await ref.data.storage.signedUrl(ref.data.path).orThrow();
```

| Method                                | Returns                                                                                     |
| ------------------------------------- | ------------------------------------------------------------------------------------------- |
| `byId(id)`                            | The bucket definition, as a `Result`                                                        |
| `has(id)`                             | Whether a bucket has that id, as a type guard                                               |
| `resolve({ bucket, path })`           | The definition and the branded `StoragePath`, after checking the path against its templates |
| `connectStored(client, ref, options)` | `resolve`, plus a connected client that has checked the path, tenant included               |
| `ids`, `buckets`                      | The registered ids, and the definitions by the names you gave                               |

The registry fails closed. An id no bucket has is an `invalid_input` error,
never a default bucket, and so is a path outside the bucket's templates.
`connectStored` takes the same options as `connect()`, so a bucket with
`tenant` refuses another tenant's path with `forbidden`. None of these
methods sends a request. Two buckets with the same id throw when the
registry is defined.

## Policies and config [#policies-and-config]

`logos.sql()` returns idempotent SQL. It upserts the bucket row and creates
`storage.objects` policies for the `authenticated` role:

| `policy`     | Access                                                                                                                                     |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `tenant`     | The `{organizationId}` segment must equal the JWT `tenant_id` (or `app_metadata.tenant_id`)                                                |
| `owner`      | The `{userId}` segment must equal `auth.uid()`                                                                                             |
| `public`     | Anyone can read. Writes need the secret key. A `public: true` bucket gets no select policy, so its public URLs work and nobody can list it |
| `none`       | No policies. Only the secret key has access                                                                                                |
| `{ access }` | The SQL modules' access contract decides, for any access model (see below)                                                                 |

`bucket.owner` names the placeholder that holds the owning user's id: the
`owner` param for `owner` buckets, otherwise `userId` when the template has
it. [`deleteAccount`](/docs/auth/account-deletion) removes the objects it
fills.

Writes are also checked against the template's shape
(`name ~ '^[^/]+/[^/]+/logo/[^/]+\.webp$'`), so users can't upload objects
outside the paths your code builds. Use `tenant: { param, claim }` or
`tenant: { sql }` to change which JWT claim or SQL expression is compared.

When the `tenant` or `owner` segment comes first in the template, the
policies also bound `name` to that segment's prefix in the `"C"` collation,
so Postgres reads one tenant's objects through the storage name index instead
of scanning the bucket. Put the tenant first in new templates to get this.

### Access contract permissions [#access-contract-permissions]

With the [access contract](/docs/blocks/access) installed, a bucket can check
the app's permissions whatever model backs them: default roles, your own
role tables, an authorization provider or your own functions. The tenant id in the path must
be one of `tenant_ids_with(key)`, or `scope: 'platform'` checks
`is_platform(key)`:

```ts title="src/lib/storage.ts"
export const contracts = defineBucket({
  id: "contracts",
  path: "{organizationId}/{contractId}/{file}",
  policy: {
    access: { read: "contracts.read", write: "contracts.write" },
  },
  tenant: { param: "organizationId" },
});
```

`list` and `delete` take their own keys (see
[provider permissions](#provider-permissions)), and `segment` picks another
path segment for the tenant id.

### Avatars and organization logos [#avatars-and-organization-logos]

`avatarBucket()` and `organizationLogoBucket()` define the two buckets most apps
need. Both are public, take 2 MiB of images (`IMAGE_TYPES`) and accept
`id`, `path`, `public`, `fileSizeLimit`, `allowedMimeTypes` and `policy`:

| Preset                   | Path                                    | Who writes                                                      |
| ------------------------ | --------------------------------------- | --------------------------------------------------------------- |
| `avatarBucket`           | `{userId}/avatar-{version}.{ext}`       | the user (`owner` policy)                                       |
| `organizationLogoBucket` | `{organizationId}/logo-{version}.{ext}` | members with `organization.update`, through the access contract |

```ts title="src/lib/storage.ts"
import { avatarBucket, organizationLogoBucket } from "better-supabase/storage";

export const avatars = avatarBucket();
export const logos = organizationLogoBucket({
  permission: "organization.branding",
});
```

`organizationLogoBucket` holds connected clients to the `{organizationId}` of their tenant;
pass `tenant: false` to upload for any organization the policy allows. A new
`{version}` per upload keeps CDN caches correct; store the path in the
profile's `avatar_url` or the organization's logo column.

### Provider permissions [#provider-permissions]

With an [authorization provider](/docs/extending/authorization-providers), an
`access` policy can call the provider's SQL functions directly, for any scope
they take, instead of the access contract. `sql: "provider"` uses the
config's `authorization.functions`, which `better-supabase gen` writes into the
generated bucket policies. An object with `idsWith` and `isPlatform` copies
the templates instead; doctor (BS214) warns when the copy differs from the
provider.

```ts title="src/lib/storage.ts"
export const files = defineBucket({
  id: "organization-files",
  path: "{organizationId}/{file}",
  policy: {
    access: {
      read: "files.download",
      list: "files.browse",
      write: "files.upload",
    },
    scope: "organization", // or 'platform'
    sql: "provider",
  },
});
```

`read` covers downloads, signed URLs, image renders and metadata reads.
With `list`, listing gets its own policy (through
`storage.allow_any_operation`), so a user can download a file they were sent
without being able to browse the folder, or the other way around. `write`
covers uploads, updates and moves, and deletes unless `delete` is set. The
scope id comes from the `{organizationId}` segment; set `segment` (1-based) for
another one. The id is compared as text, so a malformed path is denied instead
of raising a cast error, and the segment must be the id's canonical lowercase
form: a path with an uppercase uuid is denied.

The policies are only ever for `authenticated`.

#### Only for permissions without row conditions [#only-for-permissions-without-row-conditions]

The policies check role and scope, nothing else. A permission's row
conditions (for example "only files the member uploaded") are applied by the
provider's table policies, and its functions called on their own ignore them.
For a permission with row conditions, the bucket would grant every object in
the scope.

This bucket is wrong if `files.download` is granted to members only for files
they own: every member can download every file in the organization.

```ts title="src/lib/storage.ts"
// Wrong: files.download has a row condition, which the policy ignores
policy: { access: { read: "files.download", write: "files.upload" }, scope: "organization", sql },
// Right: files.browse is granted per organization, with no row condition
policy: { access: { read: "files.browse", write: "files.upload" }, scope: "organization", sql },
```

Keep row-conditioned permissions on tables, where the provider's policies
enforce them. The provider marks the keys its functions answer completely
with `sqlComplete: true`. For buckets in `better-supabase.config.ts`,
`better-supabase gen` refuses to write policies for any other key, and
[`doctor`](/docs/cli/doctor#bs214) reports BS214 for them and for generated
bucket policies in your SQL files.

`logos.toml()` gives the `[storage.buckets.customer-logos]` section for
`supabase/config.toml`. `logos.drift(actual)` compares the bucket with what
the database has. [`doctor`](/docs/cli) reports those differences.

## Client [#client]

```ts
const storage = logos.connect(supabase); // user client: policies apply

await storage.upload({ organizationId, customerId, version }, file).orThrow();
const url = await storage
  .signedUrl({ organizationId, customerId, version }, { ttl: "hour" })
  .orThrow();
const thumb = await storage
  .renderUrl(path, { width: 128, height: 128, resize: "cover" })
  .orThrow();
```

`publicUrl(target)` builds the URL of an object in a public bucket without a
request. Like `path(target)`, it returns a `Result`: `invalid_input` for a
path outside the templates and `forbidden` for another tenant's path.

```ts
const logo = storage.publicUrl(customer.logoPath);
const src = logo.ok ? logo.data : fallbackLogo;
```

Uploads are checked against `fileSizeLimit` and `allowedMimeTypes` before any
request. Failures become `DbError`s: `invalid_input` with status 413 or 415,
`forbidden` for policy denials, `conflict` for existing objects, and `network`
for 5xx responses. TTL presets are `minute`, `hour`, `day` and `week`, or pass
a number of seconds.

`copy(from, to)` and `move(from, to)` copy or move an object to another path
in the same bucket in one Storage request, and return the new path. Both
paths are checked against the template (and the tenant, below) first; an
object already at `to` is a `conflict`:

```ts
await storage
  .copy(
    { organizationId, customerId, version: "v1" },
    { organizationId, customerId, version: "v2" },
  )
  .orThrow();
```

A page that renders the same object many times can reuse its signed URL:

```ts
const storage = logos.connect(supabase, { cacheSignedUrls: true });
```

The connection then signs each path, `ttl`, `transform` and `download`
combination once and reuses the URL until a tenth of its `ttl` (at most a
minute) remains. It keeps at most 500 URLs and drops the oldest first. The
cache lives on that connection only, so connect once per request and URLs
signed for one user never reach another.

### Tenant scope [#tenant-scope]

Setting `tenant` on the bucket also makes connected clients check paths
before they reach Storage, which matters for the secret-key client that
policies don't limit. Every path the client builds, accepts, signs, uploads,
copies, moves, removes or reserves must hold the caller's tenant in the `tenant.param`
segment (`{organizationId}` by default), and `list()` lists only under it:

```ts
const logos = defineBucket({
  id: "customer-logos",
  path: "{organizationId}/{customerId}/logo/{version}.webp",
  policy: "tenant",
  tenant: { claim: "tenant_id" },
});

const storage = logos.connect(supabase, { context: db.$context });
await storage.upload(
  { organizationId: otherOrganization, customerId, version },
  file,
);
// { ok: false, error: { kind: "forbidden" } }, no request made
```

The tenant comes from `{ tenant }`, else from `context`: `context.tenant`
(which the `tenant()` plugin fills from its claims in `db.$context`), then
the `tenant.claim` claims. A
connection without a tenant gets `forbidden` for every call. Cross-tenant
admin work (account deletion does this) connects with `{ allTenants: true }`.
`storage.path(target)` and `publicUrl()`, which make no request, return
the checked path or URL as a `Result`, with the `forbidden` error for
another tenant's path.

### Image transformations [#image-transformations]

`renderUrl(target, { width, height, resize, quality })` returns a
[Storage image transformation](https://supabase.com/docs/guides/storage/serving/image-transformations)
URL. For public buckets it builds `/storage/v1/render/image/public/...` without
a request. For private buckets it signs one, and Storage answers with a
`/storage/v1/render/image/sign/...` URL whose token covers the transform, so
sign at the size you render.

With image transformations off (the Free plan, or a local stack without
`[storage.image_transformation] enabled = true`), Storage signs a plain
`/storage/v1/object/sign/...` URL instead, and the original image is served.

### next/image [#nextimage]

`better-supabase/next/image` is a [`loaderFile`](https://nextjs.org/docs/app/api-reference/components/image#loaderfile)
that serves public objects through Storage transformations instead of the
Next.js optimizer:

```ts title="src/image-loader.ts"
import { createImageLoader } from "better-supabase/next/image";

export default createImageLoader({
  url: process.env.NEXT_PUBLIC_SUPABASE_URL!,
});
```

```ts title="next.config.ts"
export default {
  images: { loader: "custom", loaderFile: "./src/image-loader.ts" },
};
```

The loader rewrites `/object/public/` and `/render/image/public/` URLs of your
project to `/render/image/public/` with the requested `width` (at most 2500)
and `quality` (20 to 100), and sets `resize` when you pass one. Signed URLs
pass through unchanged, since changing their parameters would break the
token. Local and other hosts' images go to `fallback`, which returns `src`
unchanged by default. Next.js warns in development when a loader ignores
`width`, so give those images `unoptimized` or a `fallback`.

### Replace [#replace]

Swapping an avatar or logo takes three steps, and each can fail: upload the
new file, point the row at it, delete the old file. `replace` runs them in
that order and cleans up after a failure:

```ts
const { path } = await storage
  .replace({ organizationId, customerId, version: crypto.randomUUID() }, file, {
    previous: customer.logoPath,
    commit: (path) => db.customers.update(customer.id, { logoPath: path }),
  })
  .orThrow();
```

`previous` is checked like any other target before the upload: a path
outside the templates is `invalid_input` and another tenant's path is
`forbidden`, so `replace` never removes an object the client could not
address. If `commit` throws or returns an error `Result`, the new object is
removed and the old one is kept. If removing the old object fails, the result is still ok,
with `cleanup` set to the error.

### Signed uploads [#signed-uploads]

Let the browser upload directly with a server-issued reservation:

```ts
// server
const reservation = await logos
  .connect(supabase)
  .reserve({ organizationId, customerId, version })
  .orThrow();
// browser
await logos.connect(supabase).uploadReserved(reservation, file).orThrow();
```

### Several files and progress [#several-files-and-progress]

The dropzone block in the Supabase library uploads every file again when one
fails and reports no byte progress. Since `upload` returns a `Result` per
file, keep the files that failed and retry only those:

```ts
const storage = files.connect(supabase); // path: "{organizationId}/{file}"
const results = await Promise.all(
  selected.map(async (file) => ({
    file,
    result: await storage.upload({ organizationId, file: file.name }, file),
  })),
);
const failed = results
  .filter(({ result }) => !result.ok)
  .map(({ file }) => file);
// show the errors, then call the same code with `failed` when the user retries
```

A retry can find the object already there when an earlier attempt reached
Storage but its response didn't reach the browser. That is a `conflict`; pass
`upsert: true` when overwriting is fine.

`upload` and `uploadReserved` send the file with `fetch`, which reports no
upload progress. For a progress bar, reserve the path on the server and send
the file to `reservation.signedUrl` with `XMLHttpRequest`:

```ts
const body = new FormData();
body.append("cacheControl", "3600");
body.append("", file);

const request = new XMLHttpRequest();
request.upload.onprogress = (event) => setProgress(event.loaded / event.total);
request.open("PUT", reservation.signedUrl);
request.send(body);
```

This skips the client-side size and type checks, so Storage's
`fileSizeLimit` and `allowedMimeTypes` are the ones that apply. For files
large enough to need resuming, use a TUS client against Storage's
[resumable uploads](https://supabase.com/docs/guides/storage/uploads/resumable-uploads)
endpoint. The [attachments block](/docs/blocks/attachments) records each
upload as a row and scans it before anyone can download it.

### Orphan sweep [#orphan-sweep]

Run it from a job to remove objects nothing references anymore:

```ts
const { removed } = await logos
  .connect(adminClient)
  .sweep({
    within: { organizationId },
    olderThan: Temporal.Duration.from({ hours: 24 }), // skip uploads that may still be committing
    referenced: async (paths) =>
      (
        await db.customers
          .findMany({
            where: { logoPath: { in: paths } },
            select: ["logoPath"],
          })
          .orThrow()
      ).map((row) => row.logoPath!),
  })
  .orThrow();
```

`olderThan` takes a `Temporal.Duration` (a day counts as 24 hours) or a cutoff
`Temporal.Instant`; see [Temporal](/docs/concepts/temporal).
Only objects that match a template and every `within` value are candidates,
also when a value sits after a placeholder `within` leaves out. Use `dryRun: true` to see
the `orphans` without deleting them.

## Versions and lifecycle [#versions-and-lifecycle]

A bucket with `versioning: true` keeps the earlier versions of an object when
it is overwritten or removed. `lifecycle` expires noncurrent versions, so it
needs `versioning`:

```ts title="src/lib/storage.ts"
export const contracts = defineBucket({
  id: "contracts",
  path: "{organizationId}/{contractId}.pdf",
  policy: "tenant",
  versioning: true,
  lifecycle: {
    rules: [
      {
        noncurrentVersionExpiration: {
          noncurrentDays: 90,
          newerNoncurrentVersions: 5,
        },
      },
    ],
  },
});
```

Storage keeps both settings behind its API, so `sql()` can't write them.
`apply(client)` creates or updates the bucket through the API with the secret
key, sets versioning, and sets the lifecycle policy (or removes a stored one
when `lifecycle` is unset). It is idempotent, so run it from a deploy script:

```ts
await contracts.apply(adminClient).orThrow();
```

Turning `versioning` off on a bucket that had it suspends versioning; Storage
never returns a bucket to `DISABLED`. A project where versioning or lifecycles
aren't enabled answers `unsupported`, and `apply` ignores that for the
lifecycle removal. `better-supabase doctor` (BS302) compares both settings
with `storage.buckets` when the Storage version has the columns.

The connected bucket reads and writes single versions:

```ts
const files = contracts.connect(adminClient);
const versions = await files.versions(path).orThrow(); // newest first
const previous = versions.find((version) => !version.current);
if (previous) {
  const pdf = await files
    .download(path, { versionId: previous.versionId })
    .orThrow();
  await files.copy(
    path,
    { organizationId, contractId: "restored" },
    { versionId: previous.versionId },
  );
  await files.removeVersions(path, [previous.versionId]);
}
```

`signedUrl` and `publicUrl` take `versionId` too. Each entry in `versions()`
has `versionId`, `current`, `deleteMarker`, `size`, `contentType`, `createdAt`
and `archivedAt`.

## CDN cache [#cdn-cache]

`cacheNonce` on `signedUrl`, `signedUrls`, `publicUrl` and `renderUrl` adds a
query parameter, so the CDN and browsers fetch the object again after it
changes. Pass something that changes with the object, such as its
`updatedAt`. `purgeCache(target)` invalidates the cached object on the CDN
instead; `{ transformations: true }` purges only the transformed variants. It
needs the secret key, and self-hosted Storage needs a configured CDN purge
endpoint.

## Vector buckets [#vector-buckets]

`defineVectorBucket` declares a Storage vector bucket and its indexes. Storage
keeps the vectors outside Postgres; for pgvector in a table, use the
[`vector-search` module](/docs/blocks/vector-search).

```ts title="src/lib/storage.ts"
import { defineVectorBucket } from "better-supabase/storage";

export const embeddings = defineVectorBucket({
  id: "embeddings",
  indexes: {
    documents: { dimension: 1536, distanceMetric: "cosine" },
  },
});
```

```ts
const vectors = embeddings.connect(adminClient);
await vectors.apply().orThrow(); // creates the bucket and missing indexes

const documents = vectors.index<{ organizationId: string }>("documents");
await documents
  .put([{ key: doc.id, vector, metadata: { organizationId } }])
  .orThrow();
const hits = await documents
  .query(queryVector, { topK: 5, filter: { organizationId } })
  .orThrow(); // [{ key, distance, metadata }], closest first
```

`put` sends batches of 500 and checks every vector's length against the
index's `dimension` first, so a wrong length is an `invalid_input` error
instead of a Storage server error. `apply` refuses an existing index with
another dimension or metric with `conflict`. `get(keys)` and `remove(keys)`
complete the set.

## Analytics buckets [#analytics-buckets]

`defineAnalyticsBucket({ id })` declares a Storage analytics bucket, which
holds Apache Iceberg tables. `connect(client).apply()` creates it when it is
missing, and `catalog()` returns its Iceberg REST catalog
(`storage.analytics.from(id)`) for namespaces and tables. A stack without
analytics buckets answers `unsupported`.

## Porting supabase.storage calls [#porting-supabasestorage-calls]

Each `supabase.storage.from(bucket)` call has a method on the connected
bucket that takes a template target or a path and returns a `Result`:

| supabase-js                             | Connected bucket                             |
| --------------------------------------- | -------------------------------------------- |
| `.upload(path, file, { upsert })`       | `upload(target, file, { upsert })`           |
| `.download(path)`                       | `download(target)`                           |
| `.remove([path])`                       | `remove([target])`                           |
| `.copy(from, to)` and `.move(from, to)` | `copy(from, to)` and `move(from, to)`        |
| `.exists(path)`                         | `exists(target)`, an error unless 400 or 404 |
| `.list(folder)`                         | `list(within)`, recursive and paginated      |
| `.createSignedUrl(path, ttl)`           | `signedUrl(target, { ttl })`                 |
| `.createSignedUrls(paths, ttl)`         | `signedUrls(targets, { ttl })`               |
| `.getPublicUrl(path)`                   | `publicUrl(target)`, a `Result`              |
| `.getPublicUrl(path, { transform })`    | `renderUrl(target, transform)`               |
| `.createSignedUploadUrl(path)`          | `reserve(target)`                            |
| `.uploadToSignedUrl(path, token, file)` | `uploadReserved(reservation, file)`          |
| `.download(path, { versionId })`        | `download(target, { versionId })`            |
| `.listV2({ noncurrentVersions })`       | `versions(target)`                           |
| `.remove([{ path, versionId }])`        | `removeVersions(target, [versionId])`        |
| `.purgeCache(path)`                     | `purgeCache(target)`                         |
| `storage.updateBucket`, lifecycle calls | `bucket.apply(client)`                       |
| `storage.vectors`                       | `defineVectorBucket`                         |
| `storage.analytics`                     | `defineAnalyticsBucket`                      |

`exists` is `false` only when Storage answers 400 or 404; a denied or failed
check is an error, not a missing file. `list` walks sibling folders four at
a time and returns objects in name order.

Store the path a call returns (`StoragePath`) in a `*_path` column and
resolve URLs when you read the row, so a URL never goes stale in the
database. Port one bucket at a time: `defineBucket` reads and writes the
same `storage.objects` rows, so code that still calls `supabase.storage`
keeps working on the objects the bucket writes.

# actor

> Record who created, changed and deleted each row.

Source: https://bettersupabase.com/docs/plugins/actor

```ts
import { actor } from "better-supabase/plugins/actor";

const db = defineSupabase(schema)
  .use(actor())
  .connect(supabase, { actor: { id: user.id, kind: "user" } });
```

* Inserts get `createdBy` and `updatedBy`.
* Updates get `updatedBy`. With `softDelete()`, the delete is an update, so
  `updatedBy` records who deleted the row.
* Upserts that may update only get `updatedBy`.
* Tables with an `impersonated_by` column (`plugins.actor.impersonatedBy` in
  the config renames it) get `impersonatedBy` on each insert and update made
  during [impersonation](/docs/auth/impersonation): the admin from
  `actor.impersonator`. The user's own writes leave it alone, so the row
  keeps the last impersonating admin. That is what the SQL modules'
  `track_actor(table, impersonated_by => 'impersonated_by')` writes, and it
  also keeps `created_by` on updates.
* Anonymous requests set nothing.
* `createdBy`, `updatedBy` and `impersonatedBy` values you pass yourself are refused with
  `invalid_request`, so a caller cannot sign a row as someone else. Pass
  `{ override: true }` for imports and backfills.

Server adapters fill `actor` from the verified JWT. Jobs run as the user who
enqueued them with `bs.forContext(job.context)` (see
[jobs](/docs/blocks/jobs#actor-and-tenant)), webhooks and scripts with
`bs.actingAs(userId)`, and work no user owns with `bs.admin()`, whose actor
is `service`. Pass `resolve(context)` to read the id from somewhere else.

For a tamper-proof trail, add the `actor` and `audit` SQL module triggers, which
read `auth.uid()` inside the database.

# Plugins

> First-party plugins for the columns every app repeats.

Source: https://bettersupabase.com/docs/plugins

```ts title="src/lib/supabase/index.ts"
import { defineSupabase } from "better-supabase";
import { actor } from "better-supabase/plugins/actor";
import { softDelete } from "better-supabase/plugins/soft-delete";
import { tenant } from "better-supabase/plugins/tenant";
import { timestamps } from "better-supabase/plugins/timestamps";
import { validation } from "better-supabase/plugins/validation";

import { schema } from "./generated.ts";
import { validators } from "./generated.valibot.ts";

export const betterSupabase = defineSupabase(schema)
  .use(timestamps())
  .use(softDelete())
  .use(tenant())
  .use(actor())
  .use(validation({ schemas: validators }));
```

Plugins act on tables by their generated flags. Turn the flags on in the
config and codegen marks every table that has the columns:

```ts title="better-supabase.config.ts"
plugins: {
  timestamps: true,                      // created_at, updated_at
  softDelete: { column: 'archived_at' }, // default deleted_at
  tenant: { column: 'organization_id' },
  actor: true,                           // created_by, updated_by
}
```

A table without the columns is left alone, and the types follow suit:
`restore()` and `withDeleted` only exist on soft-delete tables.

## Order [#order]

Hooks run in `use()` order, with three exceptions:

* `rules()` runs first (`enforce: 'first'`), so it checks the query as you
  wrote it.
* `softDelete()` runs next (`enforce: 'pre'`), so `timestamps()` and
  `actor()` see a soft delete as the update it becomes and stamp it.
* `validation()` runs last (`enforce: 'post'`), so it validates the row
  after `tenant()` filled `organizationId`.

| Plugin                                      | What it does                                                                            |
| ------------------------------------------- | --------------------------------------------------------------------------------------- |
| [`timestamps()`](/docs/plugins/timestamps)  | Sets `createdAt` and `updatedAt`                                                        |
| [`softDelete()`](/docs/plugins/soft-delete) | Hides deleted rows, turns `delete` into an update, adds `restore`                       |
| [`tenant()`](/docs/plugins/tenant)          | Scopes queries to the request's tenant and fills it on insert                           |
| [`actor()`](/docs/plugins/actor)            | Sets `createdBy` and `updatedBy`                                                        |
| [`validation()`](/docs/plugins/validation)  | Validates writes with any Standard Schema                                               |
| [`rules()`](/docs/plugins/rules)            | Flags unbounded reads, missing tenants, sensitive columns and admin keys in the browser |

The [lint plugin](/docs/plugins/lint) catches some of the same mistakes in
your editor.

Writing your own is covered in [Extending](/docs/extending/plugins).

# Lint rules

> Catch unbounded reads and unscoped deletes in the editor, with ESLint or oxlint.

Source: https://bettersupabase.com/docs/plugins/lint

`better-supabase/lint` is a lint plugin written against the ESLint rule API.
It has no dependencies and loads in ESLint's flat config and in oxlint's JS
plugins.

## ESLint [#eslint]

```js title="eslint.config.js"
import betterSupabase from "better-supabase/lint";

export default [betterSupabase.configs.recommended];
```

## oxlint [#oxlint]

```json title=".oxlintrc.json"
{
  "jsPlugins": ["better-supabase/lint"],
  "rules": {
    "better-supabase/no-delete-many-without-where": "error",
    "better-supabase/no-unbounded-find-many": "warn",
    "better-supabase/max-limit": ["warn", { "max": 500 }],
    "better-supabase/require-order-by": "warn",
    "better-supabase/unbounded-read": ["warn", { "tables": ["public.events"] }]
  }
}
```

## Rules [#rules]

### no-unbounded-find-many [#no-unbounded-find-many]

`findMany()` or `findMany({ ... })` without `limit`. Recommended: `warn`.

### no-delete-many-without-where [#no-delete-many-without-where]

`deleteMany()` without `where`. The repository refuses this at runtime too;
the rule catches it before it ships. Recommended: `error`.

### max-limit [#max-limit]

A literal `limit` above `max` (default 1000) in `findMany`, `findFirst` or
`paginate`. Recommended: `warn`.

### require-order-by [#require-order-by]

`findMany` with `offset` but no `orderBy`, which returns pages in no stable
order. Recommended: `warn`.

### require-max-affected [#require-max-affected]

`updateMany` or `deleteMany` without
[`maxAffected`](/docs/repository/writing#limiting-bulk-writes), or with a
literal `maxAffected` above `max` (default 1000). Not in `recommended`; turn
it on where a bulk write that matches too many rows would hurt:

```js title="eslint.config.js"
export default [
  betterSupabase.configs.recommended,
  {
    rules: { "better-supabase/require-max-affected": ["error", { max: 500 }] },
  },
];
```

### unbounded-read [#unbounded-read]

`db.<table>.findMany` without `limit` on a large table. PostgREST cuts such a
read off at `db-max-rows` without an error (see
[row caps](/docs/repository/pagination#row-caps)). Recommended: `warn`, but
it only reports once it knows which tables are large:

* `tables`: the large tables. `largeTables(snapshot)` reads them from
  `supabase/snapshot.json`, where `gen` marks every table Postgres estimates
  at 10,000 rows or more. Re-run `gen` against a database with realistic
  data (or production) to update the marks.
* `strict: true`: report on every table, like `no-unbounded-find-many`.

```js title="eslint.config.js"
import betterSupabase, { largeTables } from "better-supabase/lint";
import snapshot from "./supabase/snapshot.json" with { type: "json" };

export default [
  betterSupabase.configs.recommended,
  {
    rules: {
      "better-supabase/unbounded-read": [
        "warn",
        { tables: largeTables(snapshot) },
      ],
    },
  },
];
```

Table names match regardless of casing: `order_items`, `orderItems` and
`public.order_items` are the same table.

## How calls are matched [#how-calls-are-matched]

Rules match calls by method name with an inline object literal. When the
argument is a variable or contains a spread, the rule skips it instead of
guessing, so there are no false positives from code it can't see. The
runtime [`rules()` plugin](/docs/plugins/rules) checks what the linter
can't.

# Query rules

> Runtime checks that catch unbounded reads, missing tenants, sensitive columns and admin keys in the browser.

Source: https://bettersupabase.com/docs/plugins/rules

`rules()` checks every query before it's sent and reports what looks wrong.
It runs before every other plugin, `pre` ones included and whatever the `use()`
order, so it sees the query you wrote, before the tenant or soft-delete
plugins add their filters.

```ts
import { recommended, rules } from "better-supabase/plugins/rules";

export const betterSupabase = defineSupabase(schema).use(
  rules({ rules: recommended() }),
);
```

A `warn` violation goes to `console.warn` and the query runs. An `error`
violation fails the call with `invalid_request`, and nothing is sent.

## Presets [#presets]

| Preset          | Contents                                                                                                                             |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `safe()`        | Data exposure and correctness as errors: `noAdminInBrowser`, `noDeleteManyWithoutWhere`, `noSensitiveSelect`, `requireTenantContext` |
| `recommended()` | `safe()` plus the performance rules and `storagePathColumns` as warnings (the default)                                               |
| `strict()`      | Everything as an error, plus `requireMaxAffected`                                                                                    |

Spread a preset to change single rules:

```ts
rules({
  rules: {
    ...recommended(),
    maxLimit: ["error", 200],
    noUnboundedFindMany: "off",
  },
});
```

## Rules [#rules]

| Rule                       | Flags                                                                                                                                                                                                             | Option                                         |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| `noUnboundedFindMany`      | `findMany` without `limit`                                                                                                                                                                                        |                                                |
| `maxLimit`                 | `limit` above the maximum                                                                                                                                                                                         | maximum, default `1000`                        |
| `requireOrderByForCursor`  | `offset`, or `limit` above 1, without `orderBy`: pages overlap or skip rows. `findMany` orders by the primary key by default, so only views and keyless tables trigger it                                         |                                                |
| `maxIncludeDepth`          | Includes nested deeper than the maximum                                                                                                                                                                           | maximum depth, default `3`                     |
| `requireTenantContext`     | A tenant table queried without `context.tenant` or the tenant claim. Service connections are exempt                                                                                                               | claim, default `claims.tenant` (`'tenant_id'`) |
| `noAdminInBrowser`         | A service-role connection where `window` and `document` exist                                                                                                                                                     |                                                |
| `noSensitiveSelect`        | Reading a column listed in [`sensitive`](/docs/cli/config) without `sensitive: true` on the call                                                                                                                  |                                                |
| `noDeleteManyWithoutWhere` | A delete without `where`                                                                                                                                                                                          |                                                |
| `requireMaxAffected`       | `updateMany` or `deleteMany` without [`maxAffected`](/docs/repository/writing#limiting-bulk-writes), or with one above the maximum. Writes whose `where` pins the whole primary key are exempt                    | maximum, default `1000`                        |
| `storagePathColumns`       | A write to a `*_url` text column that is listed in [`storagePaths`](/docs/platform/storage#path-columns), or whose value is a Storage URL or matches a bucket's path template. Store the path in `*_path` instead |                                                |

`noUnboundedFindMany` skips `aggregate()`, and `maxLimit` doesn't count the
extra row `paginate()` reads to know whether there is a next page.
`requireTenantContext` reads the same claim paths as the `tenant()` plugin,
so a tenant in `app_metadata` counts. `requireMaxAffected` is only in
`strict()`; add it to another preset with `requireMaxAffected: ["error", 500]`.

`noSensitiveSelect` looks at the columns actually selected by reads,
includes too. A `select` that leaves the column out passes; to read it on
purpose:

```ts
await db.contacts.findMany({
  select: ["id", "email"],
  limit: 50,
  sensitive: true,
});
```

Writes don't trip the rule: without a `select`, `create`, `update` and
`upsert` return every column except the sensitive ones. Name the column in
`select`, or pass `sensitive: true`, to get it back.

The repository already refuses `deleteMany` without `where` on its own, so
`noDeleteManyWithoutWhere` only catches deletes built by other plugins. The
[lint rule](/docs/plugins/lint) of the same name catches the call in your
editor.

`storagePathColumns` only compares against bucket templates with a literal
part besides `/` (`{organizationId}/{customerId}/logo/{version}.webp`, not
`{userId}/{file}`), so a `website_url` holding `example.com/about` isn't
mistaken for an object.

## Reporting [#reporting]

```ts
rules({
  rules: recommended(),
  report: (violation) => logger.warn(violation),
});
```

A violation has `rule`, `level`, `table`, `operation` and `message`.
Errors still fail the call after `report` runs.

## Skipping plugins [#skipping-plugins]

`db.$withoutPlugins()` returns the same connection with no plugins at all:
no rules, tenant scoping, soft-delete filters or executor wrappers. Use it for
migrations and admin scripts, never for user requests. Pass
`{ keep: ['otel'] }` to keep tracing (or any plugin by name); a name that
isn't installed throws, so a typo can't drop a plugin silently.

# softDelete

> Hide deleted rows everywhere, restore them, and avoid the RLS returning trap.

Source: https://bettersupabase.com/docs/plugins/soft-delete

```ts
import { softDelete } from "better-supabase/plugins/soft-delete";

const db = defineSupabase(schema).use(softDelete()).connect(supabase);

await db.customers.delete(id); // sets archived_at
await db.customers.findById(id); // not_found
await db.customers.findMany({ withDeleted: true });
await db.customers.findMany({ onlyDeleted: true });
await db.customers.restore(id); // clears archived_at
await db.customers.delete(id, { hard: true }); // really deletes
```

## Where deleted rows are hidden [#where-deleted-rows-are-hidden]

* Reads, counts, `exists` and pagination on the table.
* Updates: an update never touches a deleted row, so `update` returns
  `not_found`.
* Includes: `organizations.findMany({ include: { customers: true } })` leaves
  out deleted customers.
* Relation filters: `{ customers: { some: {...} } }` only considers live rows.
  `every` is rewritten so deleted rows neither satisfy nor fail it.

`withDeleted` and `onlyDeleted` change the queried table only; nested
includes stay filtered.

## Writes [#writes]

Set the column through `delete` and `restore` only: a create or update that
passes `archivedAt` fails with `invalid_request` unless it passes
`{ override: true }`. An upsert that updates an existing row clears the
column, so upserting a deleted row brings it back.

In a [`defineApi`](/docs/specs) document the column is `readOnly` and left
out of the required fields of request bodies. A column without a comment is
described as "Set when the row is deleted. Deleted rows are not returned,
and delete sets this column instead of removing the row."

A soft delete returns no rows, so mutation events and cache invalidation get
the primary key from the `where` instead: listeners see `intent: "softDelete"`
and `keys`, and [CloudEvents](/docs/extending/events) are typed
`dev.better-supabase.row.softdeleted`.

## Indexes [#indexes]

Every read adds `archived_at is null`, so index the live rows only. A partial
index skips deleted rows, stays small as they pile up, and serves the filter
the plugin adds:

```sql
create index on customers (organization_id, name) where archived_at is null;
```

Put the columns you filter and sort by in the index, as you would without
soft delete. A unique constraint that should only hold for live rows becomes
a partial unique index:

```sql
create unique index on customers (organization_id, kvk) where archived_at is null;
```

Doctor's [BS218](/docs/cli/doctor#bs218) points at soft-delete tables without
a partial index.

## Deletes and RLS [#deletes-and-rls]

A soft delete is an `UPDATE ... SET archived_at = now()` without
`RETURNING`. That matters: a common policy hides deleted rows from `SELECT`,
and PostgREST checks returned rows against it, so an update that returns the
row fails with `forbidden`. The plugin never asks for the row back.
`better-supabase doctor` flags soft-delete tables where this would bite.

Deleting a row that is already deleted returns `not_found`. `{ hard: true }`
deletes regardless of the column.

# tenant

> Scope every query to the request's tenant, on top of RLS.

Source: https://bettersupabase.com/docs/plugins/tenant

```ts
import { tenant } from "better-supabase/plugins/tenant";

const betterSupabase = defineSupabase(schema).use(tenant());
const db = betterSupabase.connect(supabase, { claims }); // or { tenant: organizationId }
```

For tables with the tenant flag:

* reads, updates and deletes get `organization_id = <tenant>`, including
  includes and relation filters;
* inserts get the tenant column filled in;
* writing a row for another tenant, or moving a row to one, fails with
  `forbidden` before anything is sent. A numeric tenant column matches when
  its text equals the tenant;
* an upsert that updates on conflict needs the tenant column in its conflict
  target (`onConflict: ["organizationId", "kvk"]` or a unique constraint that
  includes it), so it cannot overwrite another tenant's row. The SQL and
  SQLite executors also add `where <tenant> = excluded.<tenant>` to the
  update for every table generated with the tenant flag, with or without
  this plugin. PostgREST has no such clause, so on the default executor
  the table's RLS update policy is what stops a cross-tenant upsert.

The tenant it resolves becomes `db.$context.tenant`, so jobs, storage and
cache invalidation that receive `db.$context` use the same tenant.

The tenant comes from `context.tenant`, then the JWT claim, or from your own
`resolve(context)`. `claim` takes one or more dotted paths and defaults to
`['tenant_id', 'app_metadata.tenant_id']`: a top-level claim from a custom
access token hook, or `app_metadata` set through the
Auth admin API. Set `claims.tenant` in the config to rename the claim for the
plugin, `current_tenant_id()`, and the storage and realtime policies together.
`user_metadata` is never a source, because users can write it. Server adapters
fill the context from the verified JWT.

A tenant from a URL or request body is only safe after you check it against
the user's memberships. Do that in `resolve`, or pass `{ tenant }` to
`betterSupabase.connect` once it is checked.

## In the API document [#in-the-api-document]

[`defineApi`](/docs/specs) marks the tenant column of every served tenant
table `readOnly`, described as "Set from the request's tenant.", and drops
it from the required fields of request bodies. When `resolve` reads the
tenant from a header, name it in `header` so the document lists it:

```ts
tenant({
  header: "X-Organization",
  resolve: (context) => checkedOrganization(context),
});
```

Each operation on a tenant table then has an `X-Organization` header
parameter, "The tenant the request acts for.", required unless
`onMissing: 'skip'`. The plugin doesn't read the header itself; `resolve`
does.

Pass your [claims type](/docs/auth#typed-claims) to check the paths at
compile time. Only paths to string claims, up to three levels deep, are
accepted:

```ts
const betterSupabase = defineSupabase(schema)
  .claims(Claims)
  .use(
    tenant<v.InferOutput<typeof Claims>>({ claim: "app_metadata.tenant_id" }),
  );
```

## Missing tenant [#missing-tenant]

By default a query on a tenant table without a tenant fails with
`forbidden`: the plugin fails closed. That includes a query on another table
whose includes or relation filters reach a tenant table. Use `onMissing: 'skip'` to leave it
to RLS, or `{ allTenants: true }` per call for admin tooling:

```ts
await adminDb.customers.count({ allTenants: true });
```

> **Not a replacement for RLS**
>
> The plugin makes queries correct and indexes usable; RLS makes them safe. Keep
> a tenant policy on every table. `better-supabase doctor` checks both.

# timestamps

> createdAt and updatedAt without remembering them.

Source: https://bettersupabase.com/docs/plugins/timestamps

```ts
import { timestamps } from "better-supabase/plugins/timestamps";

defineSupabase(schema).use(timestamps());
```

* Inserts get `createdAt` and `updatedAt`.
* Updates (including soft deletes) get `updatedAt`.
* Upserts that may update an existing row only get `updatedAt`, so the
  original `createdAt` survives. With `ignoreDuplicates: true` both are set.
* Values you pass yourself are refused with `invalid_request`, so a client
  cannot backdate a row. Imports and backfills pass `{ override: true }` to
  keep them:

```ts
await db.customers.create(
  { ...row, createdAt: imported.createdAt },
  { override: true },
);
```

Generated JSON Schema and OpenAPI documents mark these columns `readOnly`.
In a [`defineApi`](/docs/specs) document they are also left out of the
required fields of request bodies, and a column without a comment gets a
description: "Set by the server when the row is created." for `createdAt`
and "Set by the server on every write." for `updatedAt`.

The time comes from `defineSupabase(schema, { now })`, a function that returns
a `Temporal.Instant`, which makes tests deterministic. See
[Temporal](/docs/concepts/temporal).

> **Writes outside the app**
>
> SQL functions, dashboards and other services bypass the plugin. Add the
> `updated-at` trigger from the SQL modules (`better-supabase sql add
>   updated-at`) to cover them. The plugin and the trigger work together.

# validation

> Validate writes with zod, valibot or any Standard Schema.

Source: https://bettersupabase.com/docs/plugins/validation

```ts title="src/lib/supabase/index.ts"
import { validation } from "better-supabase/plugins/validation";

import { validators } from "./generated.zod.ts";

const betterSupabase = defineSupabase(schema).use(
  validation({ schemas: validators }),
);
```

`validators` is generated by `zod()`, `valibot()` or `standardSchema()` (no
validation library needed) and maps each table to
its `insert` and `update` schemas. You can also pass your own, per table:

```ts
validation({
  schemas: {
    customers: { insert: customerForm, update: customerForm.partial() },
  },
});
```

* Inserts and upserts use `insert`; updates use `update`.
* Validation runs on app-cased rows, after the other plugins, and its output
  is what gets written. Transforms such as trimming apply.
* Failures return a `validation` error (422) with Standard Schema issues.
  Bulk writes prefix each path with the row index: `[1, 'color']`.
* Nothing is sent when validation fails.

Any validator that implements [Standard Schema](https://standardschema.dev)
works: zod, valibot, arktype, effect schema and more.

# Aggregates

> Sums, averages, minimums, maximums and grouped counts in one request, without loading rows.

Source: https://bettersupabase.com/docs/repository/aggregates

A dashboard that loads every invoice to add up the amounts moves all those
rows to the server first. Aggregates let the database do the math and return
only the totals, in the same request as the rows they belong to.

## Enable aggregates in PostgREST [#enable-aggregates-in-postgrest]

PostgREST (12 and later) supports aggregate functions, but they are off by
default. Turn them on for the `authenticator` role in a migration:

```sql title="supabase/migrations/<timestamp>_aggregates.sql"
alter role authenticator set pgrst.db_aggregates_enabled = 'true';
notify pgrst, 'reload config';
```

Without it, PostgREST answers PGRST123. better-supabase returns that as an
`invalid_request` error whose `hint` names the setting, and
[`better-supabase doctor`](/docs/cli/doctor#bs210) warns (BS210) when your
code uses aggregates while the setting is off. Over
[`better-supabase/postgres`](/docs/auth/postgres) the same calls
compile to SQL and need no setting.

Aggregates run under RLS like any other read: they see only the rows the
caller may read, and plugins such as `tenant` and `softDelete` scope them
the same way as `findMany`.

## Per-row aggregates of a relation [#per-row-aggregates-of-a-relation]

`_sum`, `_avg`, `_min` and `_max` work like [`_count`](/docs/repository/includes#counting-related-rows):
each takes to-many relations and, per relation, the columns to aggregate:

```ts
const customers = await db.customers
  .findMany({
    select: ["id", "name"],
    include: {
      _count: { invoices: true },
      _sum: { invoices: { amount: true } },
      _max: { invoices: { issuedAt: true } },
    },
  })
  .orThrow();
// {
//   id: string; name: string;
//   _count: { invoices: number };
//   _sum: { invoices: { amount: number | null } };
//   _max: { invoices: { issuedAt: string | null } };
// }[]
```

The values are `null` when a row has no related rows. Keys follow the
configured casing, like every other column.

## Totals for a table [#totals-for-a-table]

`db.x.aggregate()` returns one result for the rows that match `where`:

```ts
const totals = await db.invoices
  .aggregate({
    where: { status: "open" },
    _count: true,
    _sum: { amount: true },
    _avg: { amount: true },
  })
  .orThrow();
// { _count: number; _sum: { amount: number | null }; _avg: { amount: number | null } }
```

## Grouped totals [#grouped-totals]

With `groupBy`, it returns one result per distinct combination of those
columns. `orderBy` sorts the groups by the `groupBy` columns; `limit` and
`offset` page through the groups:

```ts
const byStatus = await db.customers
  .aggregate({
    groupBy: ["status"],
    _count: true,
    _min: { createdAt: true },
    orderBy: { status: "asc" },
  })
  .orThrow();
// { status: 'lead' | 'active' | 'archived'; _count: number; _min: { createdAt: string | null } }[]
```

## Sort groups by an aggregate [#sort-groups-by-an-aggregate]

`orderBy` also takes `_count` and the measures, mixed with `groupBy` columns.
A list applies the terms in order, so this returns the five statuses with the
most customers, and breaks ties by status:

```ts
const top = await db.customers
  .aggregate({
    groupBy: ["status"],
    _count: true,
    orderBy: [{ _count: "desc" }, { status: "asc" }],
    limit: 5,
  })
  .orThrow();
```

A measure takes the columns it aggregates, each with a direction or
`{ direction, nulls }`: `{ _sum: { amount: "desc" } }`,
`{ _avg: { amount: "asc" } }`, `{ _min: { issuedAt: "asc" } }` and
`{ _max: { issuedAt: "desc" } }`. The sort doesn't need the aggregate in the
result, and the result type doesn't change. `_sum` and `_avg` sort by number
columns only, like the aggregates themselves.

PostgREST has no syntax to sort by an aggregate. better-supabase sends
`order=count.desc` for `_count`, which Postgres reads as `count(customers)`,
the row count of each group. That needs a table without a column named
`count`; on such a table the call returns an `invalid_request` error.
Sorting by `_sum`, `_avg`, `_min` or `_max` returns an `invalid_request`
error on PostgREST; over [`better-supabase/postgres`](/docs/auth/postgres)
and SQLite every sort compiles to `order by` in SQL.

## Value types [#value-types]

| Aggregate      | Columns        | Result                                                                               |
| -------------- | -------------- | ------------------------------------------------------------------------------------ |
| `_count`       | any            | `number`                                                                             |
| `_sum`         | numeric        | `number`; `bigint` or `string` for exact `int8`/`numeric` [codecs](/docs/cli/config) |
| `_avg`         | numeric        | `number`                                                                             |
| `_min`, `_max` | any comparable | the column's type                                                                    |

`_sum` and `_avg` only accept number columns (and numeric columns read as
strings). Passing a text column is a type error, and an `invalid_request`
error at runtime.

## Specs and query options [#specs-and-query-options]

`aggregate` is a read method, so it works everywhere a spec does:

```ts
const spec = betterSupabase.spec.customers.aggregate({
  groupBy: ["status"],
  _count: true,
});
const groups = await db.$run(spec).orThrow();

// TanStack Query
useQuery(queries.customers.aggregate({ groupBy: ["status"], _count: true }));
```

## Cost [#cost]

Each call is one request: `(count)`, `amount.sum()` and friends are rendered
into PostgREST's `select`, and into one `select ... group by` over SQL. No
related rows cross the network.

# expo-sqlite

> The same repositories over a local expo-sqlite database, without PowerSync.

Source: https://bettersupabase.com/docs/repository/expo-sqlite

```ts title="src/lib/local.ts"
import { addDatabaseChangeListener, openDatabaseAsync } from "expo-sqlite";
import { expoSqliteExecutor } from "better-supabase/expo-sqlite";
import { betterSupabase } from "./supabase";

const sqlite = await openDatabaseAsync("app.db", {
  enableChangeListener: true,
});

export const local = betterSupabase.connect(
  expoSqliteExecutor(sqlite, { addDatabaseChangeListener }),
);

const drafts = await local.notes
  .findMany({ where: { status: "draft" }, orderBy: { updatedAt: "desc" } })
  .orThrow();
```

`expoSqliteExecutor(db)` runs repository calls on a database the app owns,
with the same SQLite compiler as [PowerSync](/docs/repository/powersync).
Use it for drafts, caches and settings that never sync, or for an app that
syncs on its own schedule. The app creates the tables (with the database
column names) before the first call. `expo-sqlite` is not a dependency: the
executor reads `getAllAsync`, `runAsync` and `withTransactionAsync` from the
database it gets.

Writes run in `withExclusiveTransactionAsync` on iOS and Android, so other
queries on the same database can't see a half-done write, and in
`withTransactionAsync` on web. An insert without a text or uuid key gets
`crypto.randomUUID()`, or a UUID from SQLite's `randomblob` when Hermes has
no `crypto`.

## What runs [#what-runs]

Everything the PowerSync page lists runs the same way, and the same features
return a `DbError` of kind `unsupported`: embedding, full-text search and
function sources. `checkSqlite` from `better-supabase/expo-sqlite` checks a
list definition in a test.

## Live queries [#live-queries]

Pass `addDatabaseChangeListener` and open the database with
`enableChangeListener: true`. Then `watch()` from the same subpath, and
`useWatch()` from `better-supabase/powersync/react`, rerun a call when one of
its tables changes:

```tsx title="app/drafts.tsx"
import { useWatch } from "better-supabase/powersync/react";
import { expoSqliteDatabase } from "better-supabase/expo-sqlite";

const database = expoSqliteDatabase(sqlite, { addDatabaseChangeListener });

export function Drafts() {
  const drafts = useWatch({
    db: database,
    tables: ["notes"],
    query: () => local.notes.findMany({ where: { status: "draft" } }),
  });
  // ...
}
```

Changes to another open database file are ignored. Events arrive in batches
of at most one every 30 ms; `throttleMs` changes that.

# Filtering

> Column operators, AND/OR/NOT, and filters through relations.

Source: https://bettersupabase.com/docs/repository/filtering

## Columns [#columns]

A value means equality; `null` means `is null`:

```ts
where: { status: 'active', kvk: null }
```

Operators depend on the column type:

| Type                 | Operators                                                                          |
| -------------------- | ---------------------------------------------------------------------------------- |
| All                  | `eq`, `neq`, `in`, `notIn`, `isNull`, `not`                                        |
| Text, numbers, dates | `gt`, `gte`, `lt`, `lte`                                                           |
| Text                 | `like`, `ilike`, `match`, `imatch`, `contains`, `startsWith`, `endsWith`, `search` |
| Arrays               | `has`, `hasSome`, `hasEvery`, `containedBy`                                        |
| jsonb                | `contains` (`@>`), `path` with the operators below                                 |

`contains`, `startsWith` and `endsWith` are case-insensitive and escape `%`,
`_` and `*` in your input. Use `like` or `ilike` when you want wildcards (`%`
and `_`; a `*` in the pattern is a literal character, as in Postgres), and
`match` (`~`) or `imatch` (`~*`, case-insensitive) for a POSIX regular
expression. SQLite (PowerSync) returns `unsupported` for `match` and `imatch`.

On a jsonb column, `contains` sends its value as a JSON document, arrays
included, so an array matches a JSON array that holds every given element:

```ts
where: {
  attachments: {
    contains: [{ kind: "image" }];
  }
}
```

`path` compares the text at a key inside a jsonb column, as `->>` does.
It takes `eq`, `neq`, `in`, `notIn`, `isNull`, `gt`, `gte`, `lt`, `lte`,
`like` and `ilike`:

```ts
where: {
  metadata: { path: ['owner', 'id'], eq: userId },
  AND: [{ metadata: { path: ['replacedBy'], isNull: true } }],
}
```

This sends `metadata->owner->>id=eq.<id>`. Numbers and booleans compare as
their text, so `gt` and `lt` order strings; `isNull: true` matches a missing
key and a JSON `null`. Combine several paths on one column with `AND` or `OR`.
SQLite (PowerSync) returns `unsupported` for path filters.

A `null` in an `in` list matches rows where the column is null, and a `null`
in a `notIn` list leaves them out, so the list behaves like the values it
holds rather than like SQL's `in (..., null)`. A `Date` is sent as ISO 8601
text.

Enum and CHECK columns only accept their allowed values:

```ts
where: { status: { in: ['lead', 'active'] } }
// @ts-expect-error: 'deleted' is not a CustomersStatus
where: { status: 'deleted' }
```

## Long `in` lists [#long-in-lists]

`in` lists send uuids, numbers, dates and other plain values without quotes,
as supabase-js does. A read whose query string is still longer than
`urlLengthLimit` (6000 characters by default, below the 8 KB request line the
Supabase API gateway accepts, or the client's own `urlLengthLimit` when that is
lower) is split along its longest top-level `in` list
into reads that fit; they run in parallel, and the rows come back merged:

```ts
await db.employees.findMany({ where: { id: { in: managerIds } } });
```

Splitting needs a read without `limit`, `offset`, `count` or a page, since
the parts can't share those, and an `orderBy` on number, uuid, date or time
columns, which are sorted again after the merge (an order column you don't
select is fetched for the sort and left out of the rows). Other reads that are
too long return `invalid_request`, and so do `updateMany`, `deleteMany` and
the other writes, which can't be split: page through a shorter list, or pass
the list to a function. Set the limit with `defineSupabase(schema, { urlLengthLimit })`.

## Building a filter step by step [#building-a-filter-step-by-step]

`WhereInput` is read-only, like every argument type. The generated module
exports `WhereOf<"table">`, the same shape with writable keys, for a filter
you assemble one condition at a time:

```ts
import type { WhereOf } from "@/lib/supabase/generated";

const where: WhereOf<"customers"> = {};
if (status) where.status = status;
if (search) where.name = { ilike: `%${search}%` };
await db.customers.findMany({ where });
```

`MutableWhere<Models, T>` from `better-supabase` is the generic form.

`OrderByOf<"table">` types a sort you build outside the call, one term or a
list:

```ts
import type { OrderByOf } from "@/lib/supabase/generated";

const orderBy: OrderByOf<"customers"> =
  sort === "newest" ? { createdAt: "desc" } : [{ name: "asc" }, { id: "asc" }];
await db.customers.findMany({ where, orderBy });
```

`OrderTermOf<"table">` is one term of that sort, for a tiebreak or a
list you assemble from parts:

```ts
import type { OrderByOf, OrderTermOf } from "@/lib/supabase/generated";

const tiebreak: OrderTermOf<"customers"> = { id: "asc" };
const orderBy: OrderByOf<"customers"> = [{ name: "asc" }, tiebreak];
```

`OrderByArg<Models, T>` and `OrderByInput<Models, T>` from `better-supabase`
are the generic forms.

## Combining [#combining]

```ts
where: {
  OR: [{ name: { contains: 'acme' } }, { kvk: '1001' }],
  NOT: { status: 'archived' },
}
```

Keys in one object are combined with AND. `AND` also accepts an array.

## Indexes [#indexes]

Equality, ranges and `startsWith` on a plain btree index work as you would
expect. The other operators need a different kind of index, or every query
reads the whole table:

| Filter                                      | Index                                                                                               |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `contains`, `ilike`, `endsWith` on text     | trigram: `create extension pg_trgm`, then `create index on customers using gin (name gin_trgm_ops)` |
| `search` on a text column                   | a generated `tsvector` column with a GIN index (below)                                              |
| `contains` on jsonb                         | `create index on notes using gin (attachments jsonb_path_ops)`                                      |
| `has`, `hasSome`, `hasEvery`, `containedBy` | `create index on posts using gin (tags)`                                                            |

`search` on a text column compiles to `to_tsvector(column) @@
websearch_to_tsquery(...)`, which an index can't serve, because the
configuration is a parameter. Store the vector in a generated column instead,
index it, and search that column:

```sql
alter table customers add column search_vector tsvector
  generated always as (to_tsvector('simple', coalesce(name, '') || ' ' || coalesce(kvk, ''))) stored;
create index on customers using gin (search_vector);
```

```ts
where: {
  searchVector: {
    search: "acme";
  }
}
```

`jsonb_path_ops` makes the index smaller and faster for `@>`, which is all
`contains` uses; leave it out if SQL elsewhere uses `?`. Doctor's
[BS219](/docs/cli/doctor#bs219) flags containment filters on columns without
a GIN index.

## Relations [#relations]

To-many relations take `some`, `none` and `every`:

```ts
where: {
  notes: { some: { kind: 'call' } },      // at least one call note
  locations: { none: {} },                // no locations at all
  customerTags: { every: { tag: { color: 'green' } } },
}
```

To-one relations take a filter directly, `null` when the relation is
nullable, or `is` and `isNot`:

```ts
where: { organization: { slug: 'acme' }, primaryContact: null }
```

Relation filters nest to any depth and work inside `OR` and `NOT`. They compile
to filter-only embeds (`!inner` for `some`, anti-joins for `none` and
`every`), so they run as a single request under the caller's RLS.

> **every and NULL**
>
> `every` is "no related row fails the condition", following SQL's three-valued
> logic: a related row whose column is `NULL` does not fail `{ isPrimary: true }`.
> Add `isPrimary: { not: null }` when nulls should count as failures.

## Limits of the PostgREST path [#limits-of-the-postgrest-path]

`updateMany` and `deleteMany` cannot filter through relations over PostgREST,
and return an `invalid_request` error. Use the direct Postgres executor for
those, or select the ids first.

# Includes

> Load related rows in the same request, typed by relation cardinality.

Source: https://bettersupabase.com/docs/repository/includes

```ts
const customer = await db.customers
  .findById(id, {
    select: ["id", "name"],
    include: {
      organization: { select: ["name"] },
      primaryContact: true,
      notes: {
        select: ["id", "body"],
        where: { kind: "call" },
        orderBy: { createdAt: "desc" },
        limit: 5,
      },
      customerTags: {
        select: ["tagId"],
        include: { tag: { select: ["name", "color"] } },
      },
    },
  })
  .orThrow();
```

The result type follows the relation:

* to-one, not nullable: `organization: { name: string }`
* to-one, nullable: `primaryContact: Contact | null`
* to-many: `notes: { id: number; body: string }[]`

Includes take `select`, `include`, `where`, `orderBy` and `limit`.
`where` on a to-many include filters the related rows, not the parents. To
only return parents that have a match, add `required: true` or filter the
parent with `some`.

Includes use the foreign key name as the embed hint, so tables with several
relations to the same target never hit PostgREST's ambiguity error.

## Row level security [#row-level-security]

A `not null` foreign key types the include as non-null, but a `SELECT`
policy on the related table can still hide the row, and PostgREST then
returns `null`. Add `required: true` to drop those parents instead; the
include is then typed non-null for nullable relations too:

```ts
await db.notes.findMany({
  include: { customer: { select: ["name"], required: true } },
});
```

To make the types say so everywhere, set `relations: { nullableUnderRls: true }`
in the config: `gen` then types every to-one include of a table with row
level security as `| null`, unless the include says `required: true`.

## Counting related rows [#counting-related-rows]

`_count` counts to-many relations in the same request, without loading
them. A `where` counts only the matching rows:

```ts
const customers = await db.customers
  .findMany({
    select: ["id", "name"],
    include: {
      _count: { notes: true, locations: { where: { isPrimary: true } } },
    },
    limit: 20,
  })
  .orThrow();
// { id: string; name: string; _count: { notes: number; locations: number } }[]
```

To-one relations can't be counted; that's a type error, and an
`invalid_request` error at runtime. `_sum`, `_avg`, `_min` and `_max` work
the same way; see [Aggregates](/docs/repository/aggregates).

# Repository

> A typed repository per table, compiled to one PostgREST request.

Source: https://bettersupabase.com/docs/repository

```ts
const betterSupabase = defineSupabase(schema);
const db = betterSupabase.connect(supabase); // any SupabaseClient, or an Executor
```

`db.<table>` is created lazily for every table and view in the schema. Views
get the read methods only.

## Reading [#reading]

| Method                  | Returns                                                                                                    |
| ----------------------- | ---------------------------------------------------------------------------------------------------------- |
| `findMany(args?)`       | `Row[]`                                                                                                    |
| `findFirst(args?)`      | `Row \| null`                                                                                              |
| `findOnly({ where })`   | `Row \| null`, or a `multiple_rows` error when more than one row matches                                   |
| `findById(key, args?)`  | `Row`, or a `not_found` error                                                                              |
| `count({ where? })`     | `number` (a `HEAD` request)                                                                                |
| `exists({ where? })`    | `boolean`                                                                                                  |
| `findUnique({ where })` | `Row \| null`, by the primary key or any unique key; see [unique keys](/docs/repository/unique-and-errors) |
| `paginate(args)`        | A page; see [pagination](/docs/repository/pagination)                                                      |

`findOnly` is for a filter that should match at most one row but has no
unique key behind it, such as the active subscription of a customer. It
asks for two rows and returns a `multiple_rows` error (status 409) when it
gets both, where `findFirst` would return one of them. It returns `null`
when nothing matches, like supabase-js `maybeSingle()`.

Every read takes `select`, `where`, `include`, `orderBy`, `limit`, `offset`
and `signal`. `limit` and `offset` must be non-negative integers; anything
else returns `invalid_request` before a request is sent. The result type follows `select` and `include`:

```ts
const row = await db.customers
  .findById(id, {
    select: ["name"],
    include: { notes: { select: ["body"], limit: 3 } },
  })
  .orThrow();
// { name: string; notes: { body: string }[] }
```

Composite keys take an object: `db.customerTags.findById({ customerId, tagId })`.

## Timeouts and retries [#timeouts-and-retries]

Every read, write and `$rpc` call also takes `timeout` and `retry`:

```ts
const rows = await db.customers.findMany({
  where: { status: "lead" },
  timeout: 2000,
  retry: false,
});
```

`timeout` is in milliseconds. It is combined with `signal` (either one aborts
the request), and a call that runs past it fails with `timeout` (504) instead
of `aborted`. `retry` turns supabase-js retries on or off for the call: they
repeat `GET` and `HEAD` requests after a network error, a 503 or a 520, and
never repeat a write. Set both for a whole connection with
`betterSupabase.connect(client, context, { timeout, retry })`; a call's own
values win.

On the [Postgres executor](/docs/auth/postgres), `timeout` only stops a call
that has not started its statement yet, so use `statement_timeout` for long
queries, and `retry` does nothing.

## Escape hatches [#escape-hatches]

* `db.$client` is the client you passed in.
* `db.$rpc('fn', args, { returns })` calls a typed Postgres function, and can
  validate the result with a Standard Schema. Rows of a table and
  `returns table (...)` records come back in the configured
  [casing](/docs/concepts/casing#function-results); `{ raw: true }` keeps
  database names.
* `db.$with({ signal })` returns a Db bound to a new request context.
* `db.$table(name)` returns the repository for a table key known only at
  runtime, and throws a `TypeError` for unknown names.
* `db.$withoutPlugins()` returns the same connection without any plugins,
  for migrations and admin scripts. `db.$withoutPlugins({ keep: ['otel'] })`
  keeps the named plugins, such as tracing.
* `db.$run(spec)` runs a serializable [query spec](/docs/concepts/caching#query-specs).
* `db.$many([specA, specB])` runs specs together and returns a tuple;
  `db.$many(readSet, params)` runs a [read set](/docs/repository/read-sets)
  as one request.
* `db.$stats()` returns `{ calls, waves, tables, ms }` for everything this
  `db` (and its `$with` copies) ran. `betterSupabase.connect(client, context, { stats })`
  also reports into a shared `StatsRecorder`. See
  [Budget](/docs/frameworks/next-cache-components#budget).
* `explainPostgrest(op)` shows the compiled request for debugging.

## How queries compile [#how-queries-compile]

A call compiles to an intermediate representation, which a compiler turns
into a PostgREST request. Nothing runs through string templates you write:
values are quoted, and LIKE input used by `contains`, `startsWith` and
`endsWith` is escaped. A filter that can never match (`{ in: [] }`) skips
the request.

# Pagination

> Page numbers or offset windows with totals, or keyset cursors for infinite lists.

Source: https://bettersupabase.com/docs/repository/pagination

`paginate` takes a `page`, an `offset` window or an `after` cursor. Prefer cursors: the
cost of a page number grows with the offset, because Postgres reads and
discards every row before it, and a row inserted while someone pages shifts
the rest by one. Page numbers stay for tables where people jump to page 7
and the total matters.

## Page numbers [#page-numbers]

```ts
const { items, page } = await db.customers
  .paginate({ page: 2, size: 25, count: "exact", orderBy: { name: "asc" } })
  .orThrow();
// page: { number: 2, size: 25, total: 81, pages: 4, hasMore: true }
```

Without `count`, `total` and `pages` are `null`. `hasMore` comes from fetching
one extra row, so it never needs a count. Without `orderBy`, pages are
ordered by the primary key, so they never overlap.

## Offset and limit [#offset-and-limit]

When the caller already speaks in offsets (a data grid, a REST API with
`offset` and `limit` parameters), pass `offset` and `limit` instead of `page`
and `size`. The rows and the total come back in one request.

```ts
const { items, page } = await db.customers
  .paginate({ offset: 40, limit: 20, count: "exact", orderBy: { name: "asc" } })
  .orThrow();
// page: { number: 3, size: 20, total: 81, pages: 5, hasMore: true }
```

The result has the same shape as a numbered page: `size` is the `limit`, and
`number` is the page the offset falls on (`floor(offset / limit) + 1`). An
offset past the last row returns no items and the total on both executors,
although PostgREST itself answers that read with a 416. Passing `offset`
together with `page` or `size` is an `invalid_request` error.

## Cursors [#cursors]

```ts
const first = await db.customers
  .paginate({ after: null, size: 25, orderBy: { name: "asc" } })
  .orThrow();

const next = await db.customers
  .paginate({ after: first.nextCursor, size: 25, orderBy: { name: "asc" } })
  .orThrow();
```

Cursors use keyset pagination: the next page is "rows after the last one",
not an offset, so inserts do not shift pages and deep pages stay fast. The
primary key is added as a tiebreaker, so rows with equal sort values are
never skipped or repeated.

Nullable sort columns work too: the next page follows where Postgres puts
nulls (last for `asc`, first for `desc`, or what `nulls` says), so no row
with a null sort value is skipped. Give the order an index that matches it,
for example `create index on customers (name, id)`, and each page is one
index range scan however deep it is.

On the `better-supabase/postgres` executor the cursor compiles to a row
comparison, `(name, id) > ($1, $2)`, when every column sorts the same way and
none is nullable.

A cursor is an opaque base64url string. Pass it back with the same `orderBy`
and `where`. The cursor records the table and the sort it continues, so a
cursor passed with another `orderBy` fails with an `invalid_request` error
instead of returning the wrong page; start the new order with `after: null`.

Lists and REST resources page by cursor with `pagination: 'cursor'` (see
[list queries](/docs/platform/list#cursor-pagination)); their `size` stays capped
by `maxPageSize`.

## Row caps [#row-caps]

PostgREST returns at most `db-max-rows` rows for one read (1000 on hosted
projects, `[api] max_rows` in `config.toml`) and drops the rest without an
error. A `findMany` without `limit` can therefore look complete when it isn't.

```ts
export const betterSupabase = defineSupabase(schema, { maxRows: 1000 });

betterSupabase.on("query", ({ table, truncated }) => {
  if (truncated) metrics.increment(`db.${table}.truncated`);
});
```

* `maxRows` tells better-supabase the cap. It defaults to 1000; set it to
  what your project uses.
* A read without `limit` that returns exactly `maxRows` rows sets
  `truncated: true` on the [`query` event](/docs/extending/events) and logs
  a warning once per table. The result itself is unchanged.
* The fix is `limit` for a bounded list, `paginate` for everything else, or
  an [aggregate](/docs/repository/aggregates) when you only need a number.

## Default order [#default-order]

`findMany` without `orderBy` orders by the primary key. Postgres has no
default row order, so without it two identical reads can return rows in a
different order after an update or a `VACUUM`, and `limit` picks arbitrary
rows. Pass `orderBy` to choose another order. Views and tables without a
primary key keep the database's order.

## Sorting by a related row [#sorting-by-a-related-row]

`orderBy` takes a to-one relation with columns of the related table, one
level deep:

```ts
await db.invoices
  .paginate({
    page: 1,
    size: 25,
    orderBy: [{ organization: { name: "asc" } }, { number: "desc" }],
  })
  .orThrow();
```

Over PostgREST this sends `order=_bs1(name).asc` with an empty embed of the
relation, or reuses the relation's include when you include it. The SQL
executor sorts by a scalar subquery. Rows without a related row sort as
`null`. To-many relations, cursor pages, aggregates, sorts inside an include
and SQLite return an error, since none of them can sort by a joined row.

## Lint [#lint]

The [`unbounded-read`](/docs/plugins/lint#unbounded-read) rule flags
`findMany` without `limit` on the tables `gen` marked as large in
`supabase/snapshot.json`, before they reach production.

# PowerSync

> The same repositories over a PowerSync SQLite database on the device.

Source: https://bettersupabase.com/docs/repository/powersync

```ts title="src/lib/powersync/database.ts"
import { PowerSyncDatabase } from "@powersync/react-native";
import { powersyncExecutor } from "better-supabase/powersync";
import { betterSupabase } from "../supabase";
import { schema } from "./schema";

export const powersync = new PowerSyncDatabase({
  schema,
  database: { dbFilename: "app.db" },
});

export const local = betterSupabase.connect(powersyncExecutor(powersync));

const customers = await local.customers
  .findMany({ where: { status: "active" }, orderBy: { name: "asc" } })
  .orThrow();
```

`powersyncExecutor(db)` compiles each repository call to SQLite and runs it on
the PowerSync database. Rows come back the way PostgREST returns them: in the
definition's casing, with booleans, JSON and Temporal values decoded the same
way. `@powersync/common` is not a dependency; the executor only needs
`getAll`, `execute` and `writeTransaction`, and `onChange` for `watch`, so
`@powersync/react-native`, `@powersync/web` and a test double all work.

## What runs on SQLite [#what-runs-on-sqlite]

Filters, search, sorting, offset and cursor pages, counts, aggregates on
one table, and inserts, updates, upserts and deletes all run.
Some things have no SQLite form:

| Feature                                       | On SQLite                                 |
| --------------------------------------------- | ----------------------------------------- |
| `include` (embedding) and related counts      | `unsupported` error                       |
| full-text search (`fts`)                      | `unsupported` error                       |
| `db.$rpc` and function sources (`db.$search`) | `unsupported` error                       |
| writes to a table without a primary key       | `unsupported` error                       |
| `ilike`                                       | SQLite `like`: case-insensitive for ASCII |
| `like`                                        | `glob`: case-sensitive, like Postgres     |
| timestamps                                    | compared as UTC ISO text                  |

The error has kind `unsupported` (HTTP 501), the feature in `details`, and
nothing runs. To catch it before a screen does, check a list definition in a
test:

```ts title="src/lists.test.ts"
import { checkSqlite } from "better-supabase/powersync";

expect(await checkSqlite(betterSupabase, customerList)).toEqual({
  ok: true,
  data: undefined,
});
```

`checkSqlite` compiles every query the list runs (the page, its count and the
facet counts) without a database. It returns the first error; pass
`{ all: true }` to get every one as an array, empty when the whole list runs
on SQLite.

## Server fallback [#server-fallback]

Pass `fallback` to send what SQLite can't run to the server instead of
failing:

```ts title="src/lib/powersync/database.ts"
import { postgrestExecutor } from "better-supabase";

export const local = betterSupabase.connect(
  powersyncExecutor(powersync, {
    fallback: postgrestExecutor(supabase, betterSupabase.executorOptions()),
  }),
);
```

A read that compiles to `unsupported` (an `include`, full-text search, a
function source) runs on the fallback, and so does `db.$rpc`. Everything
else stays on SQLite. Writes never fall back, so PowerSync's upload queue
stays the only write path, and an unsupported write still returns
`unsupported`. When the device is offline, the read returns the fallback's
error, usually kind `network`.

A fallback read inside `watch` or `useWatch` reruns only when one of the
watched local tables changes. A change on the server that hasn't synced to
those tables yet doesn't show until the next local change or the next
mount.

## Porting a list from PostgREST [#porting-a-list-from-postgrest]

Most lists run locally as they are: filters, `ilike` search, facets, facet
counts, sorting, pages and one-table aggregates all compile to SQLite. The
reads that don't are usually embeddings, full-text search and database
functions that aggregate. For each, pick one:

* Sync what the screen needs. A summary table or view that a sync rule
  publishes (note counts per customer, a denormalized name) turns an RPC
  aggregate or an `include` into a local table you read and `watch`.
* Search a synced text column with `search: ["name", "kvk"]` (`ilike`)
  instead of `{ fts: ... }`.
* Keep it on the server with `fallback`, and accept that it needs a
  connection and only reruns on local changes.

To find the server-only reads, list them in a test:

```ts title="src/lists.test.ts"
import { checkSqlite } from "better-supabase/powersync";

it("runs every list on the device", async () => {
  for (const list of [customerList, noteList])
    expect(await checkSqlite(betterSupabase, list, { all: true })).toEqual([]);
});
```

With a fallback, the errors that test reports are the reads that go to the
server; each has the feature in `details`.

## Live results [#live-results]

`watch(db, query, options)` reruns a repository call whenever PowerSync
reports a change to one of `tables`, for local writes and synced rows alike:

```ts
import { sqliteTables, watch } from "better-supabase/powersync";

const stop = watch(powersync, () => customerList.run(local, query), {
  tables: sqliteTables(betterSupabase, ["customers"]),
  onResult: (result) => render(result),
});
```

`sqliteTables` maps table keys to the names PowerSync uses (`customerTags`
to `customer_tags`). The function `watch` returns stops it, and so does
`signal`; a signal that is already aborted never starts it.

Rows that didn't change keep their object identity between runs (matched by
`id`, else by position), so `memo` list items skip re-rendering, and a run
that returns the same result calls no `onResult`. `structuralSharing: false`
turns that off.

### In React [#in-react]

`better-supabase/powersync/react` wraps `watch` in a hook and adds the sync
state:

```tsx
import {
  useConflicts,
  useSyncStatus,
  useWatch,
} from "better-supabase/powersync/react";

function Customers({ search }: { search: string }) {
  const { data, error, loading } = useWatch({
    db: powersync,
    query: () =>
      customerList.run(local, { ...customerList.defaults, q: search }),
    tables,
    deps: [search],
  });
  const { hasSynced, uploading } = useSyncStatus(powersync);
  const { changes } = useConflicts(connector);
  // ...
}
```

`useWatch` restarts when `deps` or `tables` change and reports `loading`
until the first result for them; `enabled: false` pauses it.
`useSyncStatus` returns `connected`, `connecting`, `hasSynced`, `uploading`,
`downloading` and the last sync `error`, and re-renders only when one of them
changes. `useConflicts` is described in
[Offline-first](/docs/guides/offline-first#conflicts).

A read with a count (`paginate` with `count: "exact"`, a list query's total)
runs the rows and the count in one `readTransaction`, so the two agree while
PowerSync syncs.

## Table names and keys [#table-names-and-keys]

PowerSync names each view after the table without its schema, and that is
the default. Pass `tableName` to `powersyncExecutor` when your PowerSync
schema renames a table. PowerSync tables key on a text `id` that the client
creates: an insert without one gets `crypto.randomUUID()` (or `newId` when you
pass it).

Writes go to the local database and PowerSync uploads them. See
[Offline-first](/docs/guides/offline-first) for replaying them through the
repositories on the server side.

# Read sets and $many

> Run several reads as one round trip, a single GET to a stable function or one SQL transaction.

Source: https://bettersupabase.com/docs/repository/read-sets

An app shell that shows an unread badge, a customer count and the latest
note makes three requests on every navigation. Even in parallel, each costs
a connection and a trip through PostgREST. `db.$many` runs them together:
ad-hoc specs as one parallel wave, and registered read sets as a single
request.

## Ad-hoc reads [#ad-hoc-reads]

Pass an array of [query specs](/docs/concepts/caching#query-specs). The result is a tuple, typed
per entry:

```ts
const [customers, notes] = await db
  .$many([
    betterSupabase.spec.customers.findMany({
      select: ["id", "name"],
      limit: 10,
    }),
    betterSupabase.spec.notes.count(),
  ])
  .orThrow();
```

Over PostgREST the specs run in parallel, as one [wave](/docs/testing#database-budget).
Over [`better-supabase/postgres`](/docs/auth/postgres) they run on one
connection in one transaction, through `Executor.batch`. Plugins apply to
each spec as usual. The first error fails the whole call.

## Registered read sets [#registered-read-sets]

A read set names its reads once, with typed placeholders for parameters
and for the caller's id:

```ts title="src/lib/read-sets.ts"
import { defineReadSet } from "better-supabase";

import { betterSupabase } from "./supabase/index.ts";

export const workspaceSummary = defineReadSet(
  betterSupabase,
  "workspace_summary",
  {},
  (s, _p, auth) => ({
    customers: s.customers.count(),
    mine: s.customers.count({ where: { createdBy: auth.uid } }),
    latestNote: s.notes.findFirst({
      select: ["body", "createdAt"],
      orderBy: { createdAt: "desc" },
    }),
  }),
);
```

List the module in the config and add the `read-sets` SQL module:

```ts title="better-supabase.config.ts"
export default defineConfig({
  readSets: ["src/lib/read-sets.ts"],
  sql: { modules: ["read-sets"] },
});
```

`better-supabase gen` compiles each set into one function in the
[`read-sets` module](/docs/blocks/sql), then you create a migration with
`supabase db schema declarative sync` (`supabase db diff` on the legacy migra
engine):

```sql
create or replace function public.rs_workspace_summary(p jsonb)
  returns jsonb
  language sql stable security invoker set search_path = ''
as $rs$
  select jsonb_build_object(
    'customers', ...,
    'mine', ... where t0."created_by" = (select auth.uid()) ...,
    'latestNote', ...
  )
$rs$;
grant execute on function public.rs_workspace_summary(jsonb) to authenticated;
```

Then run it:

```ts
const summary = await db.$many(workspaceSummary, {}).orThrow();
// { customers: number; mine: number; latestNote: { body: string; createdAt: string } | null }
```

| Executor                   | What runs                                                                            |
| -------------------------- | ------------------------------------------------------------------------------------ |
| PostgREST                  | One GET to `rpc/rs_<name>`. The function is `stable`, so a read replica can serve it |
| `better-supabase/postgres` | The same reads with the values bound, in one transaction. The function isn't needed  |
| Anything else              | The reads in parallel                                                                |

Rows come back exactly as `db.$run(spec)` would return them: same casing,
codecs and [aggregates](/docs/repository/aggregates).

### Parameters [#parameters]

`p` holds placeholders, not values. Use them where a value goes in `where`,
including `in` lists and string filters such as `contains`. The function
reads each one from its `p jsonb` argument and casts it to the declared type,
so nothing is built as dynamic SQL.

| Type                                                  | TypeScript value              |
| ----------------------------------------------------- | ----------------------------- |
| `uuid`, `text`, `date`, `timestamp`, `timestamptz`    | `string`                      |
| `int2`, `int4`, `int8`, `float4`, `float8`, `numeric` | `number`                      |
| `bool`                                                | `boolean`                     |
| `<type>[]`                                            | `readonly` array of the above |
| `schema.type`, for example `public.note_kind`         | `string`                      |

Name enums and domains with their schema: the function runs with an empty
`search_path`. An enum column expects its literal union, so cast the
placeholder (`p.kind as 'call'`).

Every parameter is required. A missing one fails with `invalid_request`
before anything reaches the database. A spec taken from a read set still
holds placeholders, so `db.$run(readSet.specs.x)` fails too; run the set.

### The caller's id [#the-callers-id]

The third builder argument, `auth`, stands for the signed-in user.
`auth.uid` goes wherever a `uuid` value does, so the set needs no `userId`
parameter and a caller can't pass someone else's id. Inside a string filter
such as `contains` it is compared as text. It is a single value, so it can't
go in an `in` list; compare with it directly instead.

Over PostgREST the function calls `(select auth.uid())`, which reads the
`sub` claim of the request's JWT. Over `better-supabase/postgres`, and on
executors that run the reads in parallel, `$many` binds the `sub` claim of
the connection's `claims` instead, and fails with `invalid_request` when
there is none. Connect with the user's verified claims
(`connect(client, { claims })`; the server's `db` does this for you). For an
`anon` caller `auth.uid()` is null, so a comparison with it matches nothing.

### Security [#security]

The function is `security invoker`, so RLS decides what each caller reads,
as for any other query. Execute is granted to `authenticated` only; pass
`roles: ['anon', 'authenticated']` to open it up.

Read sets run without query plugins, on both executors: the function is
compiled once, and plugins such as `tenant` or `softDelete` add filters at
request time. Scope read sets with RLS, or with explicit parameters and
`where` filters. `defineReadSet` logs a warning when the definition has
query plugins and the set reads a table with a tenant or soft-delete flag.

### Limits [#limits]

* Each entry is one select: `findMany`, `findFirst`, `findUnique`,
  `findById`, `count`, `exists`, `aggregate` or an offset `paginate`.
* A set has between 1 and 50 entries.
* Names are snake\*case, at most 60 characters. The function is
  `public.rs*<name>`.
* `gen` imports the module with Node, so relative imports need their `.ts`
  extension (`allowImportingTsExtensions` in `tsconfig.json`), and the module
  can't import `server-only`.

## Caching [#caching]

In Next.js, tag a `"use cache"` scope with every table a set (or an array
of specs) reads:

```ts
export async function getWorkspaceSummary() {
  "use cache: private";
  const { db, session } = await bs.cached();
  bs.cacheTags(workspaceSummary);
  if (session.kind !== "user") return null;
  return db.$many(workspaceSummary, {}).orThrow();
}
```

Any mutation of those tables revalidates the entry. See
[Cache Components](/docs/frameworks/next-cache-components).

## Doctor [#doctor]

[BS304](/docs/cli/doctor#bs304) reports a `read-sets` file that no longer
matches the sets in `readSets`. Run `better-supabase gen` (or
`better-supabase sql sync`), then `supabase db schema declarative sync`.

# Unique keys and errors

> findUnique by any unique key, typed constraint names, and narrowing database errors.

Source: https://bettersupabase.com/docs/repository/unique-and-errors

## findUnique [#findunique]

`findUnique` takes the primary key or any unique key, as columns in your
casing. `gen` reads the unique constraints and indexes, so a `where` that
isn't a complete key is a type error.

```ts
await db.customers.findUnique({ where: { id } });
await db.customers.findUnique({ where: { organizationId, kvk } }); // customers_organization_id_kvk_key
// @ts-expect-error kvk alone is not unique
await db.customers.findUnique({ where: { kvk } });
```

It returns the row or `null`. `findById(id)` is the primary key shortcut and
fails with `not_found` instead.

## Constraint names [#constraint-names]

The generated module exports the names of your constraints:

```ts
import type {
  CheckConstraint,
  ForeignKeyConstraint,
  UniqueConstraint,
} from "./generated";
```

Pass them to the error guards to narrow an error to one constraint, with a
type error on typos:

```ts
import { isCheck, isConflict, isForeignKey } from "better-supabase";

const result = await db.customers.create({ organizationId, name, kvk });
if (
  isConflict<UniqueConstraint>(
    result.error,
    "customers_organization_id_kvk_key",
  )
) {
  return { field: "kvk", message: "This KvK number is already registered" };
}
if (isCheck<CheckConstraint>(result.error)) {
  return { message: `Invalid value (${result.error.constraint})` };
}
if (
  isForeignKey<ForeignKeyConstraint>(
    result.error,
    "customers_primary_contact_id_fkey",
  )
) {
  return { field: "primaryContactId", message: "Pick an existing contact" };
}
```

The guards accept a `DbError`, a `DbException` or anything else (they
return `false`), so they also work in a `catch` block.

## Error kinds [#error-kinds]

Every failure is a `DbError` with a `kind`:

| Kind              | Cause                                                                              |
| ----------------- | ---------------------------------------------------------------------------------- |
| `conflict`        | Unique violation (`constraint` is set)                                             |
| `check`           | CHECK violation (`constraint` is set)                                              |
| `foreign_key`     | Foreign key violation (`constraint` is set)                                        |
| `not_null`        | Missing required value                                                             |
| `not_found`       | `findById`, `update(id)` or `delete(id)` matched no row                            |
| `forbidden`       | RLS or a missing grant                                                             |
| `validation`      | A validator plugin or a `jsonb-schemas` check rejected the input (`issues` is set) |
| `invalid_request` | Caught before the request: a read-only column, an unknown field, a broken rule     |

See [Results](/docs/concepts/results) for the full list and how errors map
to HTTP Problem Details.

## Throwing your own errors [#throwing-your-own-errors]

`.orThrow()` throws a `DbException`. Pass a factory to throw something your
framework understands instead:

```ts
const customer = await db.customers
  .findById(id)
  .orThrow((error) =>
    error.kind === "not_found" ? notFound() : new Error(error.message),
  );
```

## Read-only columns [#read-only-columns]

Generated columns, `identity always` columns and view columns Postgres
marks as not insertable or updatable are missing from the `Insert` and
`Update` types. Writing one anyway (through `as any` or a spread) fails with
`invalid_request` before a request is sent, instead of a database error.

# Writing

> create, update, upsert and delete, with typed conflicts and optimistic concurrency.

Source: https://bettersupabase.com/docs/repository/writing

| Method                               | Returns                                         |
| ------------------------------------ | ----------------------------------------------- |
| `create(data, args?)`                | The row                                         |
| `createMany(data[], args?)`          | The rows                                        |
| `update(key, data, args?)`           | The row, or `not_found`                         |
| `updateMany({ where, data })`        | `{ count }`, or the rows with `returning: true` |
| `upsert(data, { onConflict })`       | The row                                         |
| `upsertMany(data[], { onConflict })` | The rows                                        |
| `delete(key)`                        | Nothing, or `not_found`                         |
| `deleteMany({ where })`              | `{ count }`, or the rows with `returning: true` |

Inserts are typed by the generated `Insert` shape: required columns are
required, columns with defaults are optional, and generated identity
columns are rejected.

## Choosing what comes back [#choosing-what-comes-back]

Writes return the full row by default. Narrow it with `select`, or skip it
with `returning: false`:

```ts
await db.customers.create(data, { select: ["id"] });
await db.auditLog.create(entry, { returning: false });
```

> **RLS and returning**
>
> Returning rows needs a `SELECT` policy that matches the new row. If users may
> insert rows they cannot read, use `returning: false`. Otherwise the insert
> fails with `forbidden` after it passed your `INSERT` policy.

`updateMany` and `deleteMany` return `{ count }`. Pass `returning: true` to get
the written rows instead, narrowed with `select` and `include` as in reads:

```ts
const sent = await db.invoices
  .updateMany({
    where: { status: "draft", dueAt: { lte: today } },
    data: { status: "sent" },
    returning: true,
    select: ["id", "number"],
  })
  .orThrow();
```

`updateMany` and `deleteMany` need a `where` that filters rows: an empty or
missing `where`, or one whose conditions are all `undefined`, returns
`invalid_request` instead of writing every row. Pass `allowAll: true` to
`updateMany` when you mean the whole table; for `deleteMany`, filter on a
column that every row matches. An `update` or `updateMany` whose `data` sets
no columns also returns `invalid_request`, unless a plugin such as
[`timestamps()`](/docs/plugins/timestamps) fills one in.

On a table with [`softDelete()`](/docs/plugins/soft-delete), `deleteMany`
with `returning: true` keeps `RETURNING` on the update it becomes, so it needs
a `SELECT` policy that still shows the soft-deleted row.

## Limiting bulk writes [#limiting-bulk-writes]

`maxAffected` caps how many rows `updateMany` and `deleteMany` may change.
When the `where` matches more, the write fails with `max_affected` (400) and
changes nothing:

```ts
const result = await db.sessions.deleteMany({
  where: { userId },
  maxAffected: 50,
});
if (result.error?.kind === "max_affected") {
  // the filter matched more rows than expected
}
```

Over PostgREST this sends `Prefer: handling=strict, max-affected=50`, which
needs PostgREST 13 or later. On an older server, set
`defineSupabase(schema, { postgrestVersion: "12.2" })` and a call with
`maxAffected` fails with `invalid_request` before any request. The
[Postgres executor](/docs/auth/postgres) and
[PowerSync](/docs/repository/powersync) count the rows inside the statement or
transaction and roll the write back. The `requireMaxAffected` rule in the
[rules plugin](/docs/plugins/rules) and the `require-max-affected`
[lint rule](/docs/plugins/lint) make the cap mandatory.

## Counting writes [#counting-writes]

`updateMany` and `deleteMany` count the written rows exactly. Pass
`count: "planned"` or `count: "estimated"` to use the Postgres planner's
estimate over PostgREST instead; `createMany` and `upsertMany` with
`returning: false` take the same option. The SQL executors always return the
statement's own row count.

## Conditional updates [#conditional-updates]

`update` matches the row by its key. Add `where` for anything else the row
must meet, such as its tenant or a status it may only leave once. A row with
this key that doesn't match comes back as `not_found`, in one request:

```ts
const result = await db.invitations.update(
  id,
  { acceptedAt: now },
  { where: { organizationId, status: { in: ["pending", "resent"] } } },
);
```

## Upserts [#upserts]

`onConflict` takes a unique constraint name from the generated `UniqueKeys`,
`'primaryKey'`, or a column list:

```ts
await db.customers.upsert(data, {
  onConflict: "customers_organization_id_kvk_key",
});
await db.tags.upsertMany(rows, {
  onConflict: ["organizationId", "name"],
  ignoreDuplicates: true,
});
```

`upsertMany` sends the rows sorted by the conflict columns, with nulls last.
Two requests that upsert overlapping rows then lock them in the same order and
wait for each other instead of deadlocking. The returned rows follow that
order, not the order you passed.

## Bulk inserts [#bulk-inserts]

`createMany` and `upsertMany` send one request over PostgREST however many
rows you pass. Rows can leave out different columns: a missing column gets
its default. Pass `defaultToNull: true` to write `null` for missing columns
instead, as supabase-js does by default. On the [Postgres executor](/docs/auth/postgres), one statement
takes at most 65,535 bind parameters (one per column value), so a larger
insert runs as several statements in one transaction: either every row is
written or none is. Clients without `transaction` (a custom `SqlClient`) run
the statements one after the other, and an error leaves the earlier ones
written.

## Optimistic concurrency [#optimistic-concurrency]

Pass the values you read in `expect`. If the row changed in the meantime, the
update fails with `stale` (412) instead of overwriting it. `expect` takes the
same operators as `where`; plain values mean equality:

```ts
const result = await db.customers.update(
  id,
  { name },
  { expect: { updatedAt } },
);
if (result.error?.kind === "stale") {
  // reload and ask the user
}
```

Telling `stale` from `not_found` takes a second request that checks whether
the row exists. Use `where` for conditions that are not about a concurrent
change, such as a tenant guard, so a miss is `not_found` without the probe.

## Conflicts [#conflicts]

Unique violations come back as `conflict` with the constraint and columns, so
forms can point at the right field:

```ts
const result = await db.tags.create({ organizationId, name });
if (
  result.error?.kind === "conflict" &&
  result.error.columns?.includes("name")
) {
  form.setError("name", { message: "Tag already exists" });
}
```

# Roadmap

> What better-supabase is building now, next and later, with the upstream source each item follows and the subpath it lands in.

Source: https://bettersupabase.com/docs/roadmap

The roadmap groups work by horizon. Each item names the upstream project it
follows and the subpath where it lands, so you can tell which import changes
when it ships. Items move between horizons as upstream releases land; the
[changelog](https://github.com/ScaleDockHQ/better-supabase/blob/main/CHANGELOG.md)
records what shipped.

## Now [#now]

* **`audience` and `issuer` pinning** (`better-supabase/server`), passed to
  `verifyCredentials`. Upstream: `@supabase/server` 1.7.
* **A Standard Schema generator** (`better-supabase/config`): `standardSchema()`
  writes Row, Insert and Update validators per table with no validation
  library. Upstream: Standard Schema and Standard JSON Schema.
* **TanStack DB collections with Realtime sync**
  (`better-supabase/tanstack-db`), built on `@supabase-labs/tanstack-db` with
  the generated schemas and primary keys. Upstream: `supabase/tanstack-db`.
* **Typed Edge Function calls** (`better-supabase/edge` and
  `better-supabase/client`): `defineFunction()` checks the input and output
  with Standard Schemas, and `functions()` calls it through `functions-js`
  with a `Result`. Upstream: `@supabase/functions-js`.
* **Supabase MCP server interop** (`better-supabase/ai-sdk/mcp` and
  `better-supabase/mcp/sdk`): `supabaseMcp()` types the server's tools with
  `createToolSchemas()`, and `supabaseMcpHandler()` serves
  `createSupabaseMcpHandler` behind better-supabase auth. Upstream:
  `@supabase/mcp-server-supabase`.

## Next [#next]

Parity gaps with what Supabase ships today.

* **Supabase Lite** (`better-supabase/server`, `better-supabase/testing` and
  the CLI): the server, repositories, `gen`, `doctor` and `init` against a
  `@supabase/lite` project, and in-process lite stacks for tests. Upstream:
  `@supabase/lite`.
* **Compiled JSON Schemas** (the `jsonb-schemas` SQL module). CHECK
  constraints use `pg_jsonschema`'s compiled `jsonschema` type once the
  platform ships a version that has it; Supabase runs 0.3.3, which has
  `jsonschema_validation_errors` but no compiled type. Upstream:
  `supabase/pg_jsonschema`.

## Later [#later]

* **AsyncAPI 3 for realtime topics** (`better-supabase/realtime`): a document
  describing each `defineTopic` channel and its payloads. Upstream: AsyncAPI 3.
* **A machine-readable capability file** for better-supabase itself, in the
  shape of the `supabase/sdk` capability matrix. Upstream: `supabase/sdk`.
* **ElectricSQL and Zero collections**: executors and TanStack DB
  collections over their sync engines, after `better-supabase/expo-sqlite`
  and `better-supabase/tanstack-db` settle the executor interface.
  Upstream: `electric-sql/electric`, `rocicorp/mono`.

The [blocks overview](/docs/blocks) lists the blocks that ship today.

# Arazzo

> Render Arazzo 1.0 or 1.1 workflows over your OpenAPI and AsyncAPI documents with defineWorkflow and the built-in workflows, checked against the operations the API has.

Source: https://bettersupabase.com/docs/specs/arazzo

`better-supabase/arazzo` renders the `workflows` of the model
[`defineApi`](/docs/specs) builds as an Arazzo document: sequences of calls
such as create a row and read it back, walk a paginated list, or sign in and
call an operation. The document goes through the same pipeline as OpenAPI
(`transform`, [overlays](/docs/specs/overlay) and the final checks), and a
step that names an operation the API doesn't have is a finding, not a
broken document.

```ts title="src/api/arazzo.ts"
import {
  arazzoFormat,
  authWorkflow,
  createThenGetWorkflows,
  defineWorkflow,
  paginationWorkflows,
  type ResourceOperationIds,
} from "better-supabase/arazzo";
import { defineApi } from "better-supabase/spec";
import { betterSupabase } from "../lib/supabase";

const onboarding = defineWorkflow<ResourceOperationIds<"customers">>({
  id: "onboardCustomer",
  inputs: { type: "object", properties: { row: { type: "object" } } },
  steps: [
    {
      id: "create",
      operationId: "createCustomers",
      requestBody: "$inputs.row",
      outputs: { id: "$response.body#/id" },
    },
    {
      id: "get",
      operationId: "getCustomers",
      parameters: { id: "$steps.create.outputs.id" },
      outputs: { row: "$response.body" },
    },
  ],
  outputs: { row: "$steps.get.outputs.row" },
});

export const api = defineApi(betterSupabase, {
  info: { title: "CRM workflows", version: "1.0.0" },
  resources: { customers: { pagination: "cursor" } },
  workflows: [
    onboarding,
    paginationWorkflows(),
    createThenGetWorkflows(),
    authWorkflow({ call: "getCustomers" }),
  ],
});

const { document, diagnostics } = api.render(arazzoFormat);
```

`authWorkflow` calls the `signIn` operation by default, so the API needs a
[route](/docs/specs/customize#routes) with that `operationId`; otherwise its
first step is an `arazzo-operation-unknown` error. `render(arazzoFormat)`
returns `document`, `version`, `fileName` (`arazzo.json`) and every finding
in `diagnostics`. A model without workflows is an `arazzo-workflows-empty`
error, because an Arazzo document needs at least one.

## createArazzo [#createarazzo]

`createArazzo(betterSupabase, options)` is the one-call form for scripts.
It takes the `defineApi` options plus `version`, `transform`, `overlays`,
`onDiagnostic` and the `openapi` and `asyncapi` source options, and returns
the document. It throws a `TypeError` that lists every diagnostic with
severity `error`; the warnings and info findings go to `onDiagnostic`.

```ts title="scripts/arazzo.ts"
import { createArazzo, createThenGetWorkflows } from "better-supabase/arazzo";
import { betterSupabase } from "../src/lib/supabase";

const document = createArazzo(betterSupabase, {
  info: { title: "CRM workflows", version: "1.0.0" },
  resources: { customers: true },
  workflows: [createThenGetWorkflows({ tables: ["customers"] })],
  openapi: { url: "https://crm.example.com/openapi.json" },
});
```

`transform` receives `{ format: "arazzo", version, document }`, typed for
the version, and runs before overlays.

## Versions [#versions]

| `version` | `arazzo` field       | Status      |
| --------- | -------------------- | ----------- |
| `"1.0"`   | `SPEC_PINS.arazzo10` | the default |
| `"1.1"`   | `SPEC_PINS.arazzo11` | stable      |

1.1 adds two things better-supabase renders:

* Channel steps, which send or receive on an AsyncAPI channel. In 1.0 a
  channel step is left out of its workflow with an
  `arazzo-channel-step-unsupported` error.
* `parameters` on success and failure actions. In 1.0 they are removed
  with an `arazzo-action-parameters-unsupported` warning.

`Arazzo10Document`, `Arazzo11Document` and `ArazzoDocument<V>` type them.

## Source descriptions [#source-descriptions]

The document lists the documents its steps call:

| Source   | Name     | URL                                     | Listed when                                          |
| -------- | -------- | --------------------------------------- | ---------------------------------------------------- |
| OpenAPI  | `api`    | the model's `self`, then `openapi.json` | a step calls an operation, or no step uses a channel |
| AsyncAPI | `events` | `asyncapi.json`                         | a 1.1 workflow has a channel step                    |

When both are listed, each `operationId` is qualified as
`$sourceDescriptions.api.<operationId>`.
`createArazzoFormat({ openapi, asyncapi })` returns a format whose sources
use your `name` and `url`;
`arazzoFormat` is `createArazzoFormat()` with the defaults, and
`createArazzo` takes the same two options.

```ts
import { createArazzoFormat } from "better-supabase/arazzo";

const published = createArazzoFormat({
  openapi: { url: "https://crm.example.com/openapi.json" },
  asyncapi: { name: "realtime", url: "https://crm.example.com/asyncapi.json" },
});

const { document } = api.render(published, { version: "1.1" });
```

## defineWorkflow [#defineworkflow]

`defineWorkflow(workflow)` returns a workflow for the `workflows` option.

| Field                    | What it is                                                                 |
| ------------------------ | -------------------------------------------------------------------------- |
| `id`                     | The `workflowId`; letters, digits, `_` and `-`                             |
| `summary`, `description` | Copied to the workflow                                                     |
| `inputs`                 | A JSON Schema, or a Standard Schema converted through Standard JSON Schema |
| `steps`                  | The steps, in order                                                        |
| `outputs`                | Output name to a runtime expression, such as `$steps.get.outputs.row`      |

A Standard Schema without Standard JSON Schema makes `defineWorkflow` throw;
pass a JSON Schema instead.

The type parameter lists the operation ids the steps may call.
`ResourceOperationIds<"customers">` is the ids `defineApi` gives a
resource by default (`listCustomers`, `getCustomers`, `createCustomers`,
`updateCustomers` and `deleteCustomers`), so a step that names another id is
a type error. A second parameter narrows the operations, and a table name
with `_` or `-` is written in PascalCase:
`ResourceOperationIds<"customer_tags", "list">` is `"listCustomerTags"`.
Without the type parameter any string is allowed, and rendering still
reports an unknown id.

### Steps [#steps]

Each step has an `id` and exactly one target:

* `operationId`: an operation of the API.
* `workflowId`: another workflow of the document.
* `channel: { id, action, correlationId }`: a channel of the model, with
  `action` `send` or `receive` (Arazzo 1.1). It renders as a `channelPath`
  into the AsyncAPI source, such as
  `{$sourceDescriptions.events.url}#/channels/tableCustomers`.

The other fields:

| Field                    | What it does                                                            |
| ------------------------ | ----------------------------------------------------------------------- |
| `parameters`             | A list of `{ name, in, value }`, or a record of name to value           |
| `requestBody`            | The body; rendered with the operation's first request content type      |
| `successCriteria`        | Condition strings or Criterion Objects (`condition`, `context`, `type`) |
| `outputs`                | Output name to a runtime expression, such as `$response.body#/id`       |
| `onSuccess`, `onFailure` | Arazzo success and failure actions (`end`, `goto`, `retry`)             |

A parameter without `in` takes the location of the operation's parameter
with that name, so `parameters: { id: "$steps.create.outputs.id" }` becomes
a `path` parameter. When the operation has no parameter by that name, the
step gets an `arazzo-parameter-in-missing` error; set `in` yourself.

Without `successCriteria`, an operation step succeeds on the operation's
first 2xx status (`$statusCode == 201` for a create, `$statusCode == 200`
for a get).

## Built-in workflows [#built-in-workflows]

The built-ins are functions of the model's operations, so they only build
workflows the API can run. Pass them in `workflows` next to your own.
`paginationWorkflows` and `createThenGetWorkflows` take `{ tables }` to
limit them to some resources.

* `paginationWorkflows()`: for each resource whose list takes the `after`
  cursor (`pagination: "cursor"`), the workflow `paginate<Table>`. It reads
  the first page with the `size` input, ends when `hasMore` is false, and
  otherwise reads the next page with the `nextCursor` the first page
  returned. Repeat the `nextPage` step with its own `nextCursor` to walk the
  rest.
* `createThenGetWorkflows()`: for each resource with `create` and `get`, the
  workflow `createThenGet<Table>`. It creates the `row` input, expects
  `201`, and reads the row back by its key.
* `authWorkflow({ call })`: the workflow `signInThen<Call>`. It signs in with
  the `email` and `password` inputs and calls `call` with the access token in
  an `Authorization: Bearer` header; the call's path parameters become
  inputs too. A 403 Problem Details answer whose `code` is
  `APPROVAL_REQUIRED` ends the workflow through the `approvalRequired`
  failure action, so a client can wait for the approval instead of
  retrying.

`authWorkflow` options:

| Option         | Default            | What it is                                               |
| -------------- | ------------------ | -------------------------------------------------------- |
| `call`         | required           | The operationId to call once signed in                   |
| `signIn`       | `signIn`           | The sign-in operationId; it takes `email` and `password` |
| `tokenPointer` | `/access_token`    | JSON Pointer to the access token in the sign-in response |
| `id`           | `signInThen<Call>` | The workflow id                                          |

## Checks [#checks]

Rendering checks each step against the model: unknown operations and
channels, parameters without a location, and channel steps or action
parameters in 1.0. The finished document is checked last, after
`transform` and overlays: duplicate and invalid ids, steps with no target
or two, calls to workflows the document doesn't have, `$steps` outputs that
no step declares or that a step reads before the step runs, `$inputs` that
aren't input properties, `$sourceDescriptions` names that aren't listed, and
`goto` or `retry` targets that don't exist.

Every code, with its fix, is on the
[diagnostics page](/docs/specs/diagnostics#arazzo). The pinned versions and
the conformance test are on the
[Arazzo standards page](/docs/standards/arazzo).

# AsyncAPI

> Render an AsyncAPI 3.0 or 3.1 document for Realtime table changes, broadcast topics, CloudEvents, outgoing webhooks and the events actions emit, from the same model as OpenAPI.

Source: https://bettersupabase.com/docs/specs/asyncapi

`better-supabase/asyncapi` renders the events side of the model
[`defineApi`](/docs/specs) builds: Realtime table change signals and
broadcast topics on the Supabase Realtime websocket, CloudEvents for row and
block events, outgoing webhooks, and the events HTTP actions emit. The
document goes through the same pipeline as OpenAPI (`transform`,
[overlays](/docs/specs/overlay) and the final checks), so a topic's
permission and a table's row schema are the same in both documents.

```ts title="src/api/asyncapi.ts"
import { asyncapiFormat } from "better-supabase/asyncapi";
import { defineApi } from "better-supabase/spec";
import { betterSupabase } from "../lib/supabase";

export const api = defineApi(betterSupabase, {
  info: { title: "CRM events", version: "1.0.0" },
  servers: [{ url: "https://crm.example.com/api", name: "production" }],
  realtimeUrl: "wss://example.supabase.co/realtime/v1/websocket",
  resources: {
    customers: {
      actions: {
        send: { method: "POST", path: "/{id}/send", emits: ["sent"] },
      },
    },
  },
  events: {
    tables: ["customers"],
    topics: {
      room: {
        template: "room:{roomId}",
        presence: true,
        events: { typing: true, sent: true },
      },
    },
    cloudEvents: {
      source: "/crm",
      rows: ["customers"],
      blocks: { "support.started": true },
    },
  },
  webhooks: [
    {
      id: "invoice.paid",
      summary: "An invoice was paid",
      payload: { type: "object", properties: { id: { type: "string" } } },
      standardWebhooks: true,
    },
  ],
});

const { document, diagnostics } = api.render(asyncapiFormat);
```

`render(asyncapiFormat)` returns `document`, `version`, `fileName`
(`asyncapi.json`) and every finding in `diagnostics`. Without `events`,
`webhooks`, `channels` or `messages` the document has no channels and no
operations.

## createAsyncApi [#createasyncapi]

`createAsyncApi(betterSupabase, options)` is the one-call form for scripts.
It takes the `defineApi` options plus `version`, `transform`, `overlays` and
`onDiagnostic`, and returns the document. It throws a `TypeError` that lists
every diagnostic with severity `error`; the warnings and info findings go to
`onDiagnostic`.

```ts title="scripts/asyncapi.ts"
import { createAsyncApi } from "better-supabase/asyncapi";
import { betterSupabase } from "../src/lib/supabase";

const document = createAsyncApi(betterSupabase, {
  info: { title: "CRM events", version: "1.0.0" },
  realtimeUrl: "wss://example.supabase.co/realtime/v1/websocket",
  events: { tables: ["customers"] },
  version: "3.1",
  onDiagnostic: (diagnostic) =>
    console.warn(diagnostic.code, diagnostic.message),
});
```

`transform` receives `{ format: "asyncapi", version, document }`, typed for
the version, and runs before overlays. Overlays need the `applyOverlays`
engine on the `defineApi` options, as for
[OpenAPI](/docs/specs/overlay).

## Versions [#versions]

| `version` | `asyncapi` field       | Status      |
| --------- | ---------------------- | ----------- |
| `"3.0"`   | `SPEC_PINS.asyncapi30` | the default |
| `"3.1"`   | `SPEC_PINS.asyncapi31` | stable      |

3.0 is the default because more tools read it. 3.1 only adds `ros2`
bindings, which better-supabase doesn't use, so the two documents differ
only in the `asyncapi` field. `AsyncApi30Document`, `AsyncApi31Document` and
`AsyncApiDocument<V>` type them.

## Servers [#servers]

`realtimeUrl` adds the `realtime` server for the websocket channels. Its
host and path come from the URL; an `http` or `ws` URL gives the protocol
`ws`, any other scheme `wss`. The server lists the model's security schemes
and carries a `ws` binding for the `apikey` (the publishable key) and `vsn`
query parameters Realtime reads on the upgrade. Without `realtimeUrl`, a
document with websocket channels gets an `asyncapi-server-missing` warning,
and a URL that isn't absolute is an `asyncapi-realtime-url-invalid` error.

HTTP channels (CloudEvents and webhooks) use the `servers` option. Each
server keeps its `name`, or is called `api`, `api2` and so on.

## Table change signals [#table-change-signals]

`events.tables` describes the change signals of the tables in
`realtime.tables` (see [live queries](/docs/frontend/live-queries)): `true`,
the default, for all of them, or a list of app keys. A key that isn't in
`realtime.tables` is an `asyncapi-table-unknown` error; `false` leaves the
tables out.

Each table gets the channel `table<Key>` with the address
`bs:t:<schema>.<table>`:

| Table setting in `realtime` | Address                          | Parameter                               |
| --------------------------- | -------------------------------- | --------------------------------------- |
| none                        | `bs:t:public.tags`               | none                                    |
| `tenant`                    | `bs:t:public.customers:{tenant}` | `tenant`, the row's tenant column value |
| `user`                      | `bs:t:public.notes:u:{userId}`   | `userId`, the signed-in user's id       |

The channel has one `change` message whose payload is `schema`, `table` and
`operation` (`INSERT`, `UPDATE` or `DELETE`). It carries no row: clients
refetch through row-level security. The `receive<Key>Changes` operation
receives it, and the channel carries `x-better-supabase-table`.

## Broadcast topics [#broadcast-topics]

`events.topics` takes Realtime broadcast topics by key. A `defineTopic()`
result from `better-supabase/realtime` fits.

| Field                    | What it does                                                                      |
| ------------------------ | --------------------------------------------------------------------------------- |
| `template`               | The address, such as `room:{roomId}`; each `{name}` becomes a channel parameter   |
| `events`                 | A payload schema per broadcast event: a Standard Schema, or `true` for any object |
| `presence`               | Adds a `presence` message and a `send` operation                                  |
| `send`                   | Clients may broadcast too, so the topic gets a `send` operation                   |
| `private`                | Defaults to `true`; written as `x-better-supabase-private`                        |
| `permission`             | The permission to join, as `x-better-supabase-permission` on the operations       |
| `name`                   | The channel title; defaults to the key                                            |
| `summary`, `description` | Copied to the channel                                                             |

The channel is `topic<Key>`, with one message per event (`topic<Key><Event>`)
and the operation `receive<Key>`. A topic without `events` is documented with
one open message and an `asyncapi-topic-open` info finding. A schema without
Standard JSON Schema leaves its payload open with an `asyncapi-schema-opaque`
warning.

## CloudEvents [#cloudevents]

`events.cloudEvents` describes the CloudEvents `forwardMutations` and
`forwardBlockEvents` send (see [CloudEvents](/docs/standards/events)). They
share the `cloudEvents` channel, at `address` (`events` by default), with
the `sendCloudEvents` operation.

| Field        | What it does                                                             |
| ------------ | ------------------------------------------------------------------------ |
| `source`     | The CloudEvents `source`, required                                       |
| `rows`       | Row events for these tables, or `true` for every table; defaults to none |
| `intents`    | Which row events; defaults to `insert`, `update` and `delete`            |
| `blocks`     | Block events and their data schemas                                      |
| `typePrefix` | Replaces the `dev.better-supabase` prefix of every type                  |
| `dataschema` | A URI, or a function of `{ type, table }` that returns one per event     |

A row event's message is `row<Table><Event>`, such as `rowCustomersCreated`
for `dev.better-supabase.row.created`. Its payload holds `table`, the `row`
(a reference to the table's Row schema when the resource is served, the
table's JSON Schema otherwise) and `actorId`.

`blocks` maps a block event type to `true` (an open payload) or a Standard
Schema for its data. The keys and the schemas are typed by `BlockEventMap`:
`BlockEventSchemas` only takes known block event types, and a schema must be
a `BlockEventSchema<T>` whose output is that event's data. A block can
export its own `BlockEventSchemas` map. The message is `block<Event>`, its
type is the prefix plus the event (`dev.better-supabase.support.started`),
and its payload gains `actorId` when the schema doesn't have it.

Every CloudEvents message lists the shared `cloudEvent` message trait
(`CLOUD_EVENT_TRAIT`): the `ce-specversion`, `ce-id`, `ce-source`,
`ce-type`, `ce-subject`, `ce-time`, `ce-dataschema` and `ce-partitionkey`
headers of the HTTP binary mode. Each message's own headers require the
four required attributes and pin `ce-type`, and `ce-dataschema` when
`dataschema` gives one. The message also carries
`x-better-supabase-cloudevent-type` and, with a data schema URI,
`x-better-supabase-dataschema`.

## Webhooks [#webhooks]

Each entry of `webhooks` has an `id` (the event name), a `payload` schema,
`standardWebhooks`, and an optional `summary` and `description`. It is the
same list OpenAPI renders as its `webhooks` section. AsyncAPI puts them on the `webhooks` channel, whose address is
`null` because each receiver has its own URL. Each one is a
`webhook<Event>` message with a `send<Event>Webhook` operation. With
`standardWebhooks: true`, the message requires the `webhook-id`,
`webhook-timestamp` and `webhook-signature` headers of
[Standard Webhooks](/docs/standards/webhooks).

## Events actions emit [#events-actions-emit]

A resource action's `emits` lists the events it sends. Each name matches a
message by its id, by its name, or by a name that ends in `.<event>`, so
`sent` finds the `sent` broadcast event and `row.created` finds the
`dev.better-supabase.row.created` row event. The
action gets a `send` operation, `<operationId>Emits` (one per channel,
`<operationId>Emits<Channel>`, when the events are on several channels),
with `x-better-supabase-operation` naming the HTTP operation. An event no
message describes is an `asyncapi-emits-unknown` warning.

## Your own channels and messages [#your-own-channels-and-messages]

`channels` and `messages` on the `defineApi` options add channels the
`events` option doesn't build. Each channel has an `id`, an `address` with
`{param}` placeholders, a `protocol` (`ws` or `http`), the ids of its
`messages` and its `operations`. Yours come after the built ones, and a
message of yours replaces a built one with the same id. The `enrich`
hooks `channel` and `message` change any of them for every version.

## Keys, schemas and security [#keys-schemas-and-security]

* AsyncAPI keys allow letters, digits, `_` and `-`. Another character in a
  channel, message or operation id becomes `_`, with an
  `asyncapi-key-invalid` info finding.
* `components.schemas` holds only the schemas the messages reference. The
  AsyncAPI Schema Object is based on JSON Schema draft-07, so a payload
  with a 2020-12 keyword (`$defs`, `prefixItems`, `unevaluatedProperties`
  and others) gets an `asyncapi-schema-dialect` warning.
* `apiKey` schemes become `httpApiKey`, and OAuth 2 flows list
  `availableScopes`. Security Profiles schemes are left out
  (`asyncapi-security-unsupported`), and a requirement that needs two
  schemes together is listed as alternatives
  (`asyncapi-security-combined`), because AsyncAPI security lists have no
  AND.

Every code, with its fix, is on the
[diagnostics page](/docs/specs/diagnostics#asyncapi). The pinned versions and
the conformance test are on the
[AsyncAPI standards page](/docs/standards/asyncapi).

# Customize the model

> Security and error presets, operation and component names, extend, custom routes, fragments, enrich hooks, plugin descriptions and schema comments in defineApi.

Source: https://bettersupabase.com/docs/specs/customize

Everything on this page is an option of `defineApi` (and of
`createOpenApi`, `buildApiModel` and the CLI that call it). Each change goes
into the version-neutral model, so every OpenAPI version gets it.

## Document fields [#document-fields]

| Option     | What it sets                                                                                  |
| ---------- | --------------------------------------------------------------------------------------------- |
| `info`     | `title`, `version` and the rest of the `info` object                                          |
| `self`     | The document's own URL (`$self` in 3.2, `x-oai-$self` before it)                              |
| `servers`  | `{ url, name?, description? }` entries                                                        |
| `basePath` | A prefix for every path, such as `/api`                                                       |
| `tags`     | Tags listed before the resource tags: `{ name, summary?, description?, parent?, kind? }`      |
| `examples` | Example rows per table (app-cased), set as the `examples` of its Row schema                   |
| `json`     | The JSON column types from `better-supabase.config.ts`, so typed `jsonb` columns get a schema |

## Resources [#resources]

`resources` maps a table's app key to `true` (every operation, `list` and
`get` for views), to a description, or to a resource an adapter serves
(anything with a `plan`, such as the handler `defineResource` returns). A
key the schema doesn't have throws.

```ts
defineApi(betterSupabase, {
  info: { title: "CRM API", version: "1.0.0" },
  resources: {
    customers: {
      list: customerList,
      pagination: "cursor",
      operations: ["list", "get", "update"],
      tag: "CRM",
      input: { update: CustomerPatch },
      output: { get: CustomerView },
    },
  },
});
```

| Field                     | Default                              | What it does                                                  |
| ------------------------- | ------------------------------------ | ------------------------------------------------------------- |
| `operations`              | all five, `list` and `get` for views | Which of `list`, `get`, `create`, `update` and `delete` exist |
| `list`                    | `page` and `size`                    | A `defineListQuery`, whose filters become query parameters    |
| `pagination`              | `offset`                             | `offset` (`page`, `size`) or `cursor` (`after`, `size`)       |
| `maxPageSize`             | 200                                  | The `maximum` of `size`; its `default` is 50                  |
| `path`, `tag`             | `/<table>`, the table key            | The collection path under `basePath`, and the operations' tag |
| `input.create`, `.update` | the `Insert` and `Update` schemas    | Replace the request body schema (any Standard Schema)         |
| `output`                  | the `Row` schema                     | Replace a response schema per operation; for `list`, the item |
| `actions`, `permissions`  | none                                 | See [resources](/docs/specs/resources)                        |
| `security`, `errors`      | the API's                            | Override them for this resource                               |
| `extend`                  | none                                 | See [extend](#extend)                                         |

`list` is `GET /<table>`, `create` is `POST /<table>` (201), `get`, `update`
(`PATCH`) and `delete` (204) are on `/<table>/{key}`. The components are
`<Table>Row`, `<Table>Insert`, `<Table>Update` and `<Table>Page`.

Every resource operation carries `x-better-supabase-table` (the
schema-qualified table) and `x-better-supabase-operation`. `extensionPrefix`
replaces the `x-better-supabase-` prefix of every generated field.

## Security presets [#security-presets]

`security` takes a list of presets, and defaults to `["bearer"]`:

| Preset                      | Scheme           | What it is                                                                                                                 |
| --------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `"bearer"`                  | `supabaseJwt`    | HTTP bearer, `bearerFormat: JWT`: the Supabase access token                                                                |
| `"oauth2"`                  | `supabaseOAuth`  | Authorization code flow on the Supabase OAuth server; needs `supabaseUrl`, and names its authorization server metadata URL |
| `"apiKey"`                  | `supabaseApiKey` | The `apikey` header                                                                                                        |
| `{ name, scheme, scopes? }` | `name`           | Any security scheme object                                                                                                 |

The `oauth2` preset's endpoints are `<supabaseUrl>/auth/v1/oauth/authorize`
and `<supabaseUrl>/auth/v1/oauth/token`, and its metadata URL is
`<origin>/.well-known/oauth-authorization-server/auth/v1`. A route or
resource with `security: []` is public.

## Error presets [#error-presets]

`errors` lists the shared error responses, each `{ name, status,
description }`. The default, `DEFAULT_ERRORS`, has `BadRequest` (400),
`Unauthorized` (401), `Forbidden` (403), `NotFound` (404), `Conflict` (409)
and `Unprocessable` (422), all with the Problem Details body
(`PROBLEM_SCHEMA`, the `Problem` component).

Each operation lists only the statuses it can answer:

| Operation | Errors                       |
| --------- | ---------------------------- |
| `list`    | 400, 401, 403                |
| `create`  | 400, 401, 403, 409, 422      |
| `get`     | 401, 403, 404                |
| `update`  | 400, 401, 403, 404, 409, 422 |
| `delete`  | 401, 403, 404, 409           |

An operation with a permission answers its 403 with the shared
`PermissionDenied` response, which has examples for `PERMISSION_DENIED`,
`INSUFFICIENT_SCOPE` and `APPROVAL_REQUIRED` (see
[permissions](/docs/specs/resources#permissions)).

## Names [#names]

`operationId({ source, method, path, defaultId })` names every operation.
Resource operations default to `<operation><Table>` (`listCustomers`),
actions to `<action><Table>`, and routes to their method and path segments
(`postReportsReportIdRun`).

`componentName(context)` names the components. For tables `context` is
`{ kind: "table", table, variant, defaultName }`, with `variant` one of
`Row`, `Insert`, `Update` and `Page`; returning `undefined` keeps
`defaultName`. For a Standard Schema used as input or output it is
`{ kind: "schema", id, schema, role, operationId }`, where `id` is the
name taken from the schema's `$id` (or `id`) and `role` is `input`,
`output` or `item`; returning `undefined` inlines the schema. Without
`componentName`, a schema with an `$id` becomes a component named after the
last segment of the `$id` (`https://schemas.example.com/NewCustomer.json`
becomes `NewCustomer`), and one without is inlined.

```ts
defineApi(betterSupabase, {
  info,
  resources: { notes: true },
  operationId: ({ defaultId, source }) =>
    source.kind === "resource" ? `crm_${defaultId}` : defaultId,
  componentName: (context) =>
    context.kind === "table" ? `Note${context.variant}` : undefined,
});
```

Two different schemas with one name are reported as `component-conflict`,
and the first one is kept.

## extend [#extend]

`extend` on a resource is keyed by operation or action name, and `*` applies
to every operation of the resource. The fields are deep-merged into the
rendered operation, after fragments, so they win. A key that names no
operation is reported as `extend-unknown`.

```ts
resources: {
  customers: {
    extend: {
      "*": { "x-team": "crm" },
      delete: { "x-audit": "required" },
    },
  },
},
```

A route takes `extend` directly.

## Routes [#routes]

`routes` documents handlers that aren't resources, such as `bs.routes` or
your own Hono routes:

```ts
defineApi(betterSupabase, {
  info,
  basePath: "/api",
  routes: [
    {
      method: "POST",
      path: "/reports/{reportId}/run",
      summary: "Run a report",
      params: ReportParams,
      query: RunQuery,
      input: RunInput,
      output: ReportRun,
      permission: "reports.run",
    },
    {
      method: "GET",
      path: "/events",
      operationId: "streamEvents",
      stream: { item: ReportEvent },
      security: [],
    },
  ],
});
```

`path` is relative to `basePath` and names parameters as `{name}`. `params`,
`query`, `input` and `output` are Standard Schemas; a path parameter
`params` doesn't describe is a string. `stream` describes a stream of
`item` values (`text/event-stream` unless `contentType` says otherwise),
rendered with `itemSchema` in 3.2 and later. `status` defaults to 200 with
an output or a stream, and 204 without. The method can also be `query`,
which only 3.2 and later render. A method outside the HTTP methods throws.

## Fragments [#fragments]

`fragments` are parsed OpenAPI objects (from YAML or JSON) deep-merged over
the rendered document, in order. Objects merge, arrays and other values
replace. A field a fragment changes is reported as `fragment-conflict`
with its JSON Pointer, so you see what it overrode. A fragment that removes
a required field throws.

```ts
import { parse } from "yaml";
import { readFileSync } from "node:fs";

defineApi(betterSupabase, {
  info,
  resources: { customers: true },
  fragments: [parse(readFileSync("openapi.extra.yaml", "utf8"))],
});
```

`mergeJson(base, patch, onConflict?)` is the merge the fragments use, and
`pointerToken(key)` escapes a key for a JSON Pointer.

## Enrich hooks [#enrich-hooks]

`enrich` changes the model itself, before any version renders it. Each hook
gets one item and returns a replacement, or `undefined` to keep it:

| Hook                       | Gets                    |
| -------------------------- | ----------------------- |
| `operation(operation)`     | each operation          |
| `schema(schema, { name })` | each component schema   |
| `message(message, { id })` | each message (AsyncAPI) |
| `channel(channel)`         | each channel (AsyncAPI) |
| `workflow(workflow)`       | each workflow (Arazzo)  |

```ts
defineApi(betterSupabase, {
  info,
  resources: { customers: true },
  enrich: {
    operation: (operation) =>
      operation.method === "delete"
        ? { ...operation, description: "Deletes need an admin." }
        : undefined,
  },
});
```

The hooks run last while the model is built, so they see the plugin
descriptions and the resource defaults.

## Plugin descriptions [#plugin-descriptions]

A plugin with a [`describe` hook](/docs/extending/plugins#api-descriptions) adds
what it does to the served tables: the first-party plugins mark the columns
they set `readOnly` and drop them from the required fields of the request
bodies.

| Plugin       | Adds                                                                                    |
| ------------ | --------------------------------------------------------------------------------------- |
| `timestamps` | the created and updated columns are `readOnly`, with a description of when they are set |
| `softDelete` | the soft-delete column is `readOnly`                                                    |
| `tenant`     | the tenant column is `readOnly`; with `header`, a header parameter on tenant tables     |

A `describe` hook that throws is reported as `plugin-describe-failed`, and
the rest of the model is built.

## Comments become descriptions [#comments-become-descriptions]

`comment on table` and `comment on column` become the `description` of the
table's schemas and fields, unless a schema already has one. A comment line
that starts with `@deprecated` marks the table or column `deprecated`, and
the operations of a deprecated table are deprecated too. `@example` lines
are left out of the description (the [validator generators](/docs/cli/gen#documentation-in-the-validators)
read them).

```sql title="supabase/schemas/customers.sql"
comment on column public.customers.kvk is 'Chamber of Commerce number.
@deprecated Use registration_number.';
```

## Permissions in the document [#permissions-in-the-document]

With an `authorizer`, each permission reference becomes its `key()`, written
as `x-better-supabase-permission` on the operation. Without one, a
permission must be a string, and anything else is reported as
`permission-unresolved`. `requirePermissions: true` reports every write
(any operation other than `list` and `get`, and routes and actions whose
method isn't `GET`, `HEAD`, `OPTIONS` or `QUERY`) that declares no permission
as `permission-missing`. `permissionScopes: "keys"` adds each permission key
as a scope of the `oauth2` preset and of the operation's requirement.

# Diagnostics

> Every code defineApi, the OpenAPI, AsyncAPI and Arazzo renderers and their checks report, with its severity, its cause and how to fix it.

Source: https://bettersupabase.com/docs/specs/diagnostics

Building and rendering an API document never fails silently. Each finding
is a `SpecDiagnostic`:

```ts
interface SpecDiagnostic {
  code: string;
  severity: "error" | "warning" | "info";
  message: string;
  pointer?: string; // a JSON Pointer into the model or the document
}
```

`defineApi(...).diagnostics` holds the findings from building the model, and
every render result's `diagnostics` adds the ones from that render. The
list has no duplicates. `createOpenApi`, `createAsyncApi` and `createArazzo`
throw on any `error` and pass the rest to `onDiagnostic`. Match on `code`; the message text can change.

The [overlay codes](/docs/specs/overlay#diagnostics) are on the overlay
page.

## Building the model [#building-the-model]

| Code                     | Severity | Cause                                                                                                  | Fix                                                                                 |
| ------------------------ | -------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- |
| `component-conflict`     | error    | Two different schemas got the same component name; the first one is kept                               | Give one of them another `$id`, or rename it in `componentName`                     |
| `operation-id-duplicate` | error    | Two operations have the same id                                                                        | Set `operationId` on the route, or change the `operationId` option                  |
| `permission-unresolved`  | error    | The authorizer's `key()` threw for a permission, or a permission that isn't a string has no authorizer | Pass the `authorizer` to `defineApi`, or fix the reference                          |
| `permission-missing`     | error    | With `requirePermissions: true`, a write declares no permission                                        | Add a `permission` to the route or action, or `permissions` to the resource         |
| `schema-unconvertible`   | warning  | A Standard Schema has no Standard JSON Schema, so the document accepts any value there                 | Use a schema library that implements Standard JSON Schema, such as zod 4 or ArkType |
| `extend-unknown`         | warning  | A resource's `extend` names no operation or action of it                                               | Fix the key, or use `*` for every operation                                         |
| `plugin-describe-failed` | warning  | A plugin's `describe` hook threw; the rest of the model is built                                       | Fix the plugin                                                                      |

## Rendering [#rendering]

| Code                   | Severity | Cause                                                                                              | Fix                                                                                |
| ---------------------- | -------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `preview-version`      | warning  | The version is a preview (`3.3-preview`), whose fields can change                                  | Render a stable version for anything you publish                                   |
| `preview-only`         | info     | A Security Profiles scheme is left out of a version before 3.3, with the requirements that name it | Expected; render `3.3-preview` to see it                                           |
| `method-unsupported`   | warning  | A `query` operation is left out of 3.0 or 3.1                                                      | Render 3.2, or add a `GET` or `POST` route for old clients                         |
| `webhooks-unsupported` | warning  | 3.0 has no webhooks, so they are left out                                                          | Render 3.1 or later                                                                |
| `fragment-conflict`    | warning  | A fragment replaced a generated value; the pointer says which                                      | Expected when you meant to override it; otherwise use `extend` or fix the fragment |

## Checks on the finished document [#checks-on-the-finished-document]

These run last, after `transform` and overlays, so they also catch what
those broke.

| Code                     | Severity | Cause                                                           |
| ------------------------ | -------- | --------------------------------------------------------------- |
| `operation-id-duplicate` | error    | Two operations in the document share an `operationId`           |
| `security-unresolved`    | error    | A security requirement names a scheme the document doesn't have |
| `path-param-unknown`     | error    | A path parameter is declared but isn't in the path template     |
| `path-param-missing`     | error    | A `{name}` in the path template has no parameter                |
| `ref-unresolved`         | error    | A local `$ref` (one that starts with `#`) points nowhere        |

## AsyncAPI [#asyncapi]

[`asyncapiFormat`](/docs/specs/asyncapi) reports these while it renders
the `events`, `channels`, `messages` and `webhooks` of the model.

| Code                                 | Severity | Cause                                                                                                 | Fix                                                                                  |
| ------------------------------------ | -------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `asyncapi-table-unknown`             | error    | `events.tables` names a table that isn't in `realtime.tables`, or `cloudEvents.rows` an unknown table | Fix the key, or add the table to `realtime.tables`                                   |
| `asyncapi-realtime-url-invalid`      | error    | `realtimeUrl` isn't an absolute URL                                                                   | Pass the full websocket URL, such as `wss://<ref>.supabase.co/realtime/v1/websocket` |
| `asyncapi-message-unknown`           | error    | A channel lists a message id that no message has; it is left out                                      | Add the message to `messages`, or fix the id                                         |
| `asyncapi-operation-message-unknown` | error    | An operation lists a message that isn't one of its channel's                                          | List only messages of the operation's channel                                        |
| `asyncapi-operation-id-duplicate`    | error    | Two operations have the same id; the first one is kept                                                | Rename one of the channel operations or topics                                       |
| `asyncapi-channel-id-duplicate`      | error    | Two channels have the same id after invalid characters become `_`; the first one is kept              | Give one of the channels another id                                                  |
| `asyncapi-server-missing`            | warning  | The document has websocket channels but no `realtimeUrl`                                              | Set `realtimeUrl`                                                                    |
| `asyncapi-schema-opaque`             | warning  | A topic event or block event schema has no Standard JSON Schema, so its payload is left open          | Use a schema library that implements Standard JSON Schema, or pass `true`            |
| `asyncapi-schema-dialect`            | warning  | A message payload or headers use JSON Schema 2020-12 keywords the draft-07 Schema Object lacks        | Expected when your tools read 2020-12; otherwise rewrite the schema without them     |
| `asyncapi-emits-unknown`             | warning  | An action's `emits` names an event no message describes                                               | Fix the name, or describe the event in `events` or `messages`                        |
| `asyncapi-topic-open`                | info     | A topic lists no `events`, so it has one open message                                                 | Add `events` to document the payloads                                                |
| `asyncapi-key-invalid`               | info     | A channel, message or operation id has characters AsyncAPI keys don't allow; it is written with `_`   | Expected; use letters, digits, `_` and `-` to keep the id                            |
| `asyncapi-security-unsupported`      | info     | A Security Profiles scheme is left out, because AsyncAPI can't describe it                            | Expected                                                                             |
| `asyncapi-security-combined`         | info     | A requirement that needs several schemes together is listed as alternatives                           | Expected; AsyncAPI security lists have no AND                                        |

These checks run on the finished document, after `transform` and overlays:

| Code                                 | Severity | Cause                                                        |
| ------------------------------------ | -------- | ------------------------------------------------------------ |
| `asyncapi-ref-unresolved`            | error    | A local `$ref` points nowhere                                |
| `asyncapi-key-invalid`               | error    | A channel id has characters AsyncAPI keys don't allow        |
| `asyncapi-parameter-unknown`         | error    | A channel parameter isn't in the channel's address           |
| `asyncapi-parameter-missing`         | error    | A `{name}` in a channel's address has no parameter           |
| `asyncapi-operation-channel-unknown` | error    | An operation doesn't reference a channel under `#/channels/` |
| `asyncapi-operation-message-unknown` | error    | An operation lists a message outside its channel             |

## Arazzo [#arazzo]

[`arazzoFormat`](/docs/specs/arazzo) reports these while it renders the
model's `workflows`.

| Code                                   | Severity | Cause                                                                         | Fix                                                                     |
| -------------------------------------- | -------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `arazzo-workflows-empty`               | error    | The model has no workflows, and an Arazzo document needs one                  | Pass `workflows`, or a built-in such as `paginationWorkflows()`         |
| `arazzo-operation-unknown`             | error    | A step calls an operationId the API doesn't have                              | Fix the id, or serve the operation; type it with `ResourceOperationIds` |
| `arazzo-parameter-in-missing`          | error    | A parameter has no `in`, and the operation has no parameter by that name      | Set `in`, or fix the parameter name                                     |
| `arazzo-channel-step-unsupported`      | error    | A step uses an AsyncAPI channel in Arazzo 1.0; the step is left out           | Render version `1.1`                                                    |
| `arazzo-channel-unknown`               | error    | A channel step names a channel the model doesn't have                         | Fix the id, or add the channel through `events` or `channels`           |
| `arazzo-action-parameters-unsupported` | warning  | A success or failure action has `parameters` in Arazzo 1.0; they are left out | Render version `1.1`                                                    |

These checks run on the finished document, after `transform` and overlays:

| Code                           | Severity | Cause                                                                                                |
| ------------------------------ | -------- | ---------------------------------------------------------------------------------------------------- |
| `arazzo-id-invalid`            | error    | A workflow or step id has characters other than letters, digits, `_` and `-`                         |
| `arazzo-workflow-id-duplicate` | error    | Two workflows have the same id                                                                       |
| `arazzo-step-id-duplicate`     | error    | A workflow has two steps with the same id                                                            |
| `arazzo-step-target`           | error    | A step has none, or more than one, of `operationId`, `operationPath`, `channelPath` and `workflowId` |
| `arazzo-workflow-unknown`      | error    | A step calls a workflow the document doesn't have                                                    |
| `arazzo-step-output-unknown`   | error    | A `$steps.<step>.outputs.<name>` expression names an output no such step declares                    |
| `arazzo-step-output-order`     | error    | A step reads the output of itself or of a later step                                                 |
| `arazzo-source-unknown`        | error    | A `$sourceDescriptions.<name>` expression names a source the document doesn't list                   |
| `arazzo-goto-unknown`          | error    | A `goto` or `retry` action targets a step or workflow that doesn't exist                             |
| `arazzo-input-undeclared`      | warning  | An `$inputs.<name>` expression names a property the workflow's `inputs` don't declare                |

## Fail a build on errors [#fail-a-build-on-errors]

```ts title="scripts/openapi.ts"
const { document, diagnostics } = api.openapi({ version: "3.1" });

const errors = diagnostics.filter(
  (diagnostic) => diagnostic.severity === "error",
);
if (errors.length > 0) {
  for (const error of errors)
    console.error(`${error.code}: ${error.message} ${error.pointer ?? ""}`);
  process.exit(1);
}
```

# API documents

> Describe your API once with defineApi, then render OpenAPI 3.0, 3.1, 3.2 or the 3.3 preview from the same model and serve it with an ETag.

Source: https://bettersupabase.com/docs/specs

`better-supabase/spec` builds one version-neutral model of your API from the
schema metadata, the plugins and the resources you serve. Each document
format renders that model: OpenAPI 3.0, 3.1, 3.2 and `3.3-preview` today. You
describe the API once, and every version describes the same paths, schemas
and errors as the routes your adapter serves.

```ts title="src/api/spec.ts"
import { defineApi } from "better-supabase/spec";
import { defineListQuery } from "better-supabase/list";
import { betterSupabase } from "../lib/supabase";

const customerList = defineListQuery(betterSupabase, "customers", {
  search: ["name"],
  facets: { status: "status" },
});

export const api = defineApi(betterSupabase, {
  info: { title: "CRM API", version: "1.0.0" },
  basePath: "/api",
  resources: { customers: { list: customerList }, tags: true },
});

const { document, diagnostics } = api.openapi({ version: "3.2" });
```

`defineApi(betterSupabase, options)` returns:

| Field         | What it is                                                                                |
| ------------- | ----------------------------------------------------------------------------------------- |
| `model`       | The `ApiModel`: operations, schemas, security, tags, webhooks and fragments, in no format |
| `diagnostics` | What building the model found (see [diagnostics](/docs/specs/diagnostics))                |
| `openapi()`   | Renders OpenAPI; `version` defaults to `"3.1"`                                            |
| `render()`    | Renders any [document format](/docs/extending/document-formats) for one of its versions   |

The model is built once per options object: two `defineApi` calls with the
same `betterSupabase`, plugins and options share it. Each format and version
is rendered once too, and every call returns a fresh copy, so changing a
returned document never changes the next one. The same options give the
same bytes on every run.

`buildApiModel(betterSupabase, options)` returns `{ model, diagnostics }`
without the renderers, for tools that read the model directly. It throws a
`TypeError` for a table key the schema doesn't have.

## Render a version [#render-a-version]

```ts
const { document, version, fileName, diagnostics } = api.openapi({
  version: "3.0",
});
```

`version` is `"3.0"`, `"3.1"`, `"3.2"` or `"3.3-preview"`. The result's
`document` is typed for that version, `fileName` is `openapi.json`, and
`diagnostics` holds the model's findings plus the ones from rendering and
from the checks that run on the finished document. A version the format
doesn't have throws. [OpenAPI versions](/docs/specs/openapi) lists what each
version renders differently.

`createOpenApi(betterSupabase, options)` from `better-supabase/openapi` is
the one-call form for scripts and the CLI. It takes the same options plus
`version`, `transform`, `overlays` and `onDiagnostic`, returns the document,
and throws a `TypeError` that lists every diagnostic with severity `error`.
The other diagnostics go to `onDiagnostic`.

```ts title="scripts/openapi.ts"
import { createOpenApi } from "better-supabase/openapi";

const document = createOpenApi(betterSupabase, {
  info: { title: "CRM API", version: "1.0.0" },
  resources: { customers: true },
  version: "3.1",
  onDiagnostic: (diagnostic) =>
    console.warn(diagnostic.code, diagnostic.message),
});
```

## Where to customize [#where-to-customize]

The stages, from the earliest to the latest. Use the earliest one that can
express the change, so every version gets it:

1. Options and presets: `info`, `servers`, `tags`, `security`, `errors`,
   naming (see [customize the model](/docs/specs/customize)).
2. `extend`: fields merged into one operation or into every operation of a
   resource.
3. Schema metadata: `comment on table` and `comment on column` become
   descriptions, and `@deprecated` marks a table or column deprecated.
4. `enrich` hooks: functions that replace an operation, schema, channel,
   message or workflow in the model, for every version at once.
5. `transform`: a function that gets the rendered document of one version,
   typed for it.
6. [Overlays](/docs/specs/overlay): OpenAPI Overlay documents applied to the
   rendered document, for changes another team or tool owns.

## Transform one version [#transform-one-version]

`transform` receives `{ format, version, document }`, with `document` typed
for the version. Return a replacement, or change `document` in place and
return nothing. It runs before overlays and the final checks, so a field it
breaks is still reported.

```ts
const { document } = api.openapi({
  version: "3.2",
  transform: ({ version, document }) => {
    if (version !== "3.2") return;
    return { ...document, info: { ...document.info, title: "CRM API (beta)" } };
  },
});
```

`render(format, options)` takes the same `version`, `transform`, `overlays`
and `applyOverlays` for any format.

## Serve the document [#serve-the-document]

`serializeDocument(document, { indent, sortKeys })` turns a document into
stable JSON: two-space indent by default, keys in insertion order unless
`sortKeys`, a trailing newline, and `undefined` values left out. It throws
on `NaN`, `Infinity` and other values that have no JSON form.

`specResponse(document, request, options)` answers a request for the
document. It sends an `ETag` (the base64url SHA-256 of the body) and
`Cache-Control: public, no-cache`, answers `304 Not Modified` when
`If-None-Match` matches (weak comparison), sends headers only for `HEAD`, and
answers `405` with `Allow: GET, HEAD` for any other method. The body is
built once per document object.

```ts title="src/app/openapi.json/route.ts"
import { specResponse } from "better-supabase/spec";
import { api } from "../../api/spec";

const { document } = api.openapi({ version: "3.1" });

export const GET = (request: Request) => specResponse(document, request);
export const HEAD = GET;
```

`cacheControl`, `contentType` (default `application/json`) and `indent`
change the response.

`specReference` serves an interactive reference page for the document next
to that route: Scalar by default, or Swagger UI, Redoc, Stoplight Elements
or RapiDoc (see [Reference UIs](/docs/specs/reference-ui)). To write the
documents to files and check them in CI, use
[`better-supabase spec`](/docs/cli/spec).

## Pages in this section [#pages-in-this-section]

* [Customize the model](/docs/specs/customize): security and error
  presets, naming, `extend`, custom routes, fragments, `enrich` and plugins.
* [OpenAPI versions](/docs/specs/openapi): what 3.0, 3.1, 3.2 and the 3.3
  preview render.
* [AsyncAPI](/docs/specs/asyncapi): `better-supabase/asyncapi`, for Realtime
  channels, CloudEvents and webhooks.
* [Arazzo](/docs/specs/arazzo): `better-supabase/arazzo`, for workflows over
  the OpenAPI and AsyncAPI documents.
* [Overlays](/docs/specs/overlay): `better-supabase/overlay`.
* [Reference UIs](/docs/specs/reference-ui): `specReference` and
  `scalarPreset`, for an interactive reference page.
* [Diagnostics](/docs/specs/diagnostics): every code, its severity and its
  fix.
* [Resources](/docs/specs/resources): the REST routes the adapters serve
  from the same descriptions, with hooks, actions and permissions.

The versions and their pins are on the
[OpenAPI standards page](/docs/standards/openapi).

# OpenAPI versions

> What OpenAPI 3.0, 3.1, 3.2 and the 3.3 preview render from the same model, and which fields fall back to x- extensions in older versions.

Source: https://bettersupabase.com/docs/specs/openapi

`api.openapi({ version })` renders one OpenAPI version from the model
[`defineApi`](/docs/specs) builds. The versions describe the same operations,
schemas and errors; they differ only where an older version has no field for
something.

| `version`       | `openapi` field | Status                     |
| --------------- | --------------- | -------------------------- |
| `"3.0"`         | `3.0.4`         | legacy, for older tools    |
| `"3.1"`         | `3.1.2`         | the default                |
| `"3.2"`         | `3.2.1`         | stable                     |
| `"3.3-preview"` | `3.3.0`         | preview, never the default |

```ts
const legacy = api.openapi({ version: "3.0" }).document;
const current = api.openapi().document; // 3.1
const latest = api.openapi({ version: "3.2" }).document;
```

The pinned spec versions and the conformance tests are on the
[OpenAPI standards page](/docs/standards/openapi).

## Every version [#every-version]

* Path parameters that every operation on a path shares move to the Path
  Item, so they are declared once.
* Webhooks from the `webhooks` option become the root `webhooks` section
  (3.1 and later). Each one is a `post` with the operation id `on<Event>`, a
  JSON request body, and a `2XX` response. With `standardWebhooks: true` it
  also lists the `webhook-id`, `webhook-timestamp` and `webhook-signature`
  headers.
* Fragments are merged after the document is rendered, then each
  operation's `extend` (see [customize the model](/docs/specs/customize)).
* The finished document is checked: duplicate operation ids, security
  requirements that name no scheme, path parameters without a declaration
  and the reverse, and local `$ref`s that point nowhere. See
  [diagnostics](/docs/specs/diagnostics#checks-on-the-finished-document).

## 3.1 [#31]

3.1 is the default and what `createOpenApi` renders without a `version`. It
sets `jsonSchemaDialect` to JSON Schema 2020-12, which is the dialect of
every generated schema.

The fields that only 3.2 defines are kept as extensions, so tools that know
them can still read them:

| Model field                         | In 3.2              | In 3.0 and 3.1                                  |
| ----------------------------------- | ------------------- | ----------------------------------------------- |
| The document's URL (`self`)         | `$self`             | `x-oai-$self`                                   |
| A server's `name`                   | `name`              | `x-oai-name`                                    |
| A response's `summary`              | `summary`           | `x-oai-summary`                                 |
| A streamed item schema              | `itemSchema`        | `x-oai-itemSchema`                              |
| The OAuth 2 metadata URL            | `oauth2MetadataUrl` | `x-better-supabase-oauth2MetadataUrl`           |
| A tag's `summary`, `parent`, `kind` | native              | `x-better-supabase-summary`, `-parent`, `-kind` |

The `x-better-supabase-` fields follow `extensionPrefix`.

## 3.2 [#32]

3.2 renders those fields natively:

* `$self` on the document and `name` on each server.
* Nested tags: `parent`, `kind` and `summary` on each tag.
* `itemSchema` for a [route](/docs/specs/customize#routes) with `stream`, so
  each event of a `text/event-stream` response has a schema.
* `oauth2MetadataUrl` on the `oauth2` preset, pointing at the Supabase Auth
  server metadata.
* The `query` method. A route with `method: "query"` is a `query` operation
  in 3.2 and later, and is left out of 3.0 and 3.1 with a
  `method-unsupported` warning.

## 3.0 [#30]

3.0 is for tools that don't read 3.1 yet. The document has no
`jsonSchemaDialect` and no `webhooks` (a model with webhooks gets a
`webhooks-unsupported` warning), and `info.summary` and
`license.identifier` are left out. Every schema is rewritten into the
OpenAPI 3.0 schema object, after the fragments are merged:

| 2020-12                                        | 3.0                                     |
| ---------------------------------------------- | --------------------------------------- |
| `type: ["string", "null"]`                     | `type: "string"`, `nullable: true`      |
| A `null` branch in `anyOf` or `oneOf`          | `nullable: true` on the schema          |
| A type list with several types                 | `anyOf` of each type                    |
| `const`                                        | a one-value `enum`                      |
| `examples`                                     | `example` (the first one)               |
| numeric `exclusiveMinimum`, `exclusiveMaximum` | the bound plus `exclusiveMinimum: true` |
| `contentEncoding: base64`                      | `format: byte`                          |
| `$ref` with sibling keywords                   | `allOf` with the `$ref`                 |
| keywords 3.0 doesn't have                      | left out                                |

`toOpenApi30Schema(schema)` from `better-supabase/openapi` is that
rewrite, for schemas you serve yourself.

## 3.3 preview [#33-preview]

`"3.3-preview"` follows the OpenAPI `v3.3-dev` branch and the Security
Profiles proposal at the commits pinned in `SPEC_PINS`. It is never the
default: every render adds a `preview-version` warning, and the document
carries `x-better-supabase-preview` with the pinned commit. Fields can
change before 3.3 is released, so use it to try the proposal, not to
publish.

Besides everything 3.2 renders, the preview:

* moves a `security` that every operation on a path shares to the Path
  Item;
* renders security schemes of the Security Profiles type (`type:
  "profile"`) and the `securityProfileRequirements` option as
  `components.securityProfileRequirements`.

```ts
const preview = api.openapi({ version: "3.3-preview" });
// preview.diagnostics has { code: "preview-version", severity: "warning" }
```

Other versions leave profile schemes out, with every requirement that names
one, and report `preview-only` (severity `info`).

## Types [#types]

`OpenApi30Document`, `OpenApi31Document`, `OpenApi32Document` and
`OpenApi33PreviewDocument` from `better-supabase/openapi` type each version,
and `OpenApiDocument<V>` picks one by version. `transform` and the result of
`api.openapi({ version })` use them, so a field another version doesn't
have is a type error.

# Overlays

> Apply OpenAPI Overlay 1.0, 1.1 and 1.2 documents to a rendered spec with better-supabase/overlay, write overlays in TypeScript, and export your changes as one.

Source: https://bettersupabase.com/docs/specs/overlay

An [Overlay](https://spec.openapis.org/overlay/latest.html) is a list of
actions, each a JSONPath `target` plus an `update`, a `remove` or (from 1.1)
a `copy`. Use one for changes another team or tool owns, such as a public
variant of an internal API. Changes that belong to every version of your own
API fit better in [`extend` or `enrich`](/docs/specs/customize).

`better-supabase/overlay` reads Overlay 1.0, 1.1 and 1.2. Targets are RFC
9535 JSONPath queries, run by the optional peer `jsonpath-rfc9535`:

```bash
pnpm add jsonpath-rfc9535
```

## Apply an overlay [#apply-an-overlay]

```ts
import { applyOverlay, loadJsonPath } from "better-supabase/overlay";

const jsonpath = await loadJsonPath();
const { document, diagnostics } = applyOverlay(openapi, overlay, { jsonpath });
```

`applyOverlay(document, overlay, { jsonpath, allowEmpty })` returns
`{ document, diagnostics }`. It copies the document once and never changes
the input. Actions run in order, each on the result of the one before:

* `update` merges into objects, appends to arrays (an array value is
  concatenated), and replaces other values.
* `remove: true` deletes every selected node, and wins over `update` and
  `copy`. Removing the document root is invalid.
* `copy` (1.1 and later) is a JSONPath that must select exactly one node,
  whose value is used like `update`.

Problems never throw. They come back as diagnostics, with a JSON Pointer to
the action (`/actions/2`), and you decide what fails a build.
`applyOverlays(document, overlays, options)` applies several in order and
prefixes each pointer with the overlay's index (`/1/actions/2`).

`loadJsonPath()` imports `jsonpath-rfc9535` on first use and caches it.
Without the package it rejects with the install command. Any RFC 9535
engine fits instead: pass `{ paths(document, expression) }` that returns
the normalized paths of the selected nodes and throws for an invalid query.

## With defineApi [#with-defineapi]

`api.openapi({ overlays })` applies overlays after `transform` and before
the final checks, so a broken reference an overlay leaves is reported. It
needs the engine as `applyOverlays`, on the `defineApi` options or on the
call; without one, passing `overlays` throws.

```ts title="src/api/spec.ts"
import { applyOverlays, loadJsonPath } from "better-supabase/overlay";
import { defineApi } from "better-supabase/spec";

const jsonpath = await loadJsonPath();

export const api = defineApi(betterSupabase, {
  info: { title: "CRM API", version: "1.0.0" },
  resources: { customers: true },
  applyOverlays: (document, overlays) =>
    applyOverlays(document, overlays, { jsonpath }),
});

const { document } = api.openapi({ overlays: [publicOverlay] });
```

## Write an overlay in TypeScript [#write-an-overlay-in-typescript]

`defineOverlay(input)` checks an overlay when you write it and types each
action:

```ts title="src/api/public.overlay.ts"
import { defineOverlay } from "better-supabase/overlay";

export const publicOverlay = defineOverlay({
  info: { title: "Public API", version: "1.0.0" },
  actions: [
    {
      target: "$.paths['/customers'].get",
      update: { summary: "List customers" },
    },
    { target: "$.paths[?@['x-internal'] == true]", remove: true },
  ],
});
```

`overlay` defaults to `1.1.0`, or to `1.2.0` when the input uses
`components`, `$self` or a `$ref` action. It throws for an overlay without
actions, a 1.2 feature in an older version, `copy` in 1.0, a `$ref` to a
reusable action that doesn't exist, and a `#` in `extends` or `$self`.

## Reusable actions (1.2) [#reusable-actions-12]

Overlay 1.2 puts shared actions under `components.actions`, and an action
refers to one with `$ref: "#/components/actions/<name>"`. The fields the
reference sets override the shared ones.

```ts
defineOverlay({
  info: { title: "Deprecations", version: "1.0.0" },
  components: {
    actions: {
      deprecate: { fields: { update: { deprecated: true } } },
    },
  },
  actions: [
    {
      target: "$.paths['/legacy'].get",
      $ref: "#/components/actions/deprecate",
    },
  ],
});
```

`expandReusableActions(overlay)` returns `{ overlay, diagnostics }`: an
Overlay 1.1 document with every reference replaced by its fields, for tools
that only read 1.1. It leaves out `$self` and `components`.

## Targets that select nothing [#targets-that-select-nothing]

A target that selects nothing is usually a typo or a path that moved, so it
is an `overlay-no-match` error. For an action that may legitimately select
nothing, set the `x-better-supabase-allow-empty` extension on it, or pass
`allowEmpty: true` to accept it for every action:

```ts
{
  target: "$.paths['/beta'].get",
  remove: true,
  "x-better-supabase-allow-empty": true,
}
```

## Export your changes as an overlay [#export-your-changes-as-an-overlay]

`overlayFromDiff(base, changed, options)` writes the difference between two
documents as an Overlay, so a tool that doesn't run your code can reproduce
what your hooks changed:

```ts
import { overlayFromDiff } from "better-supabase/overlay";

const plain = createOpenApi(betterSupabase, { info, resources });
const enriched = createOpenApi(betterSupabase, { info, resources, enrich });
const overlay = overlayFromDiff(plain, enriched, {
  info: { title: "CRM enrichments", version: "1.0.0" },
});
```

Targets are normalized paths of object keys, such as
`$['paths']['/customers']['get']`. A removed key becomes a `remove`, new keys become one
`update` on their parent, and a changed value an `update` (in 1.0, a
`remove` followed by an `update`). An array that only grew gets its new
items appended; any other array change replaces the array. `version`
defaults to `1.1.0`, `info` to `better-supabase changes` version `1.0.0`,
and `extends` sets the target document's URI. It throws when either
document isn't an object, or when they are equal.

## Diagnostics [#diagnostics]

| Code                          | Severity | When                                                                                                                                                                         |
| ----------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `overlay-no-match`            | error    | A target selects nothing, without `x-better-supabase-allow-empty` or `allowEmpty`                                                                                            |
| `overlay-invalid-target`      | error    | A target is not a valid JSONPath query                                                                                                                                       |
| `overlay-invalid-action`      | error    | The overlay has no `actions` list, or an action is not an object, has no `target`, or removes the root; a warning for a reusable action that sets `target`, which is ignored |
| `overlay-mixed-targets`       | error    | An update's target selects nodes of different kinds (objects and arrays, say)                                                                                                |
| `overlay-merge-conflict`      | error    | The value doesn't fit a selected node: an array or primitive merged into an object, or an object or array in place of a primitive                                            |
| `overlay-copy-source`         | error    | A `copy` source doesn't select exactly one node                                                                                                                              |
| `overlay-ref-unresolved`      | error    | A `$ref` doesn't point under `#/components/actions/`, or names no reusable action                                                                                            |
| `overlay-version-feature`     | error    | `copy` in a 1.0 overlay (the action is skipped); a warning for a 1.2 reusable action in a 1.0 or 1.1 overlay, which is still expanded                                        |
| `overlay-version-unsupported` | error    | The `overlay` field is not a 1.0.x, 1.1.x or 1.2.x version                                                                                                                   |
| `overlay-empty-action`        | warning  | An action has neither `update`, `remove` nor `copy`                                                                                                                          |

The versions and the conformance tests are on the
[standards page](/docs/standards).

# Reference UIs

> Serve an interactive API reference for your OpenAPI document with Scalar, Swagger UI, Redoc, Stoplight Elements or RapiDoc, loaded from pinned jsDelivr versions with SRI or from your own assets.

Source: https://bettersupabase.com/docs/specs/reference-ui

`specReference` from `better-supabase/spec` answers a request with an HTML
page that renders your OpenAPI document in a reference UI. Serve it next to
the [`specResponse`](/docs/specs#serve-the-document) route that serves the
document:

```ts title="src/app/api/openapi.json/route.ts"
import { specResponse } from "better-supabase/spec";
import { api } from "../../../lib/api";

const { document } = api.openapi();

export const GET = (request: Request) => specResponse(document, request);
export const HEAD = GET;
```

```ts title="src/app/api/docs/route.ts"
import { specReference } from "better-supabase/spec";

export const GET = (request: Request) =>
  specReference({ specUrl: "/api/openapi.json", csp: true }, request);
export const HEAD = GET;
```

The page loads the document from `specUrl` in the browser, so the two
routes stay independent: the document keeps its `ETag`, and the page holds
no copy of it. In Hono, mount both on the app:

```ts title="src/server.ts"
import { createHono } from "better-supabase/hono";
import { specReference, specResponse } from "better-supabase/spec";
import { api } from "./lib/api";
import { betterSupabase } from "./lib/supabase";

const bs = createHono(betterSupabase);
const { document } = api.openapi();

export const app = bs
  .app()
  .get("/api/openapi.json", (c) => specResponse(document, c.req.raw))
  .get("/api/docs", (c) =>
    specReference({ specUrl: "/api/openapi.json", ui: "redoc" }, c.req.raw),
  );
```

`specReference(options, request)` answers `GET` and `HEAD` (headers only),
and `405` with `Allow: GET, HEAD` for any other method. The response is
`text/html; charset=utf-8` with `Cache-Control: no-cache` unless you pass
`cacheControl`.

## The five UIs [#the-five-uis]

`ui` picks the UI. Scalar is the default.

| `ui`       | Package                 | Pinned version |
| ---------- | ----------------------- | -------------- |
| `scalar`   | `@scalar/api-reference` | 1.73.1         |
| `swagger`  | `swagger-ui-dist`       | 5.33.1         |
| `redoc`    | `redoc`                 | 2.5.4          |
| `elements` | `@stoplight/elements`   | 9.0.28         |
| `rapidoc`  | `rapidoc`               | 10.1.0         |

`SPEC_UIS` lists the names and `SPEC_UI_PACKAGES` the package and version
of each. An unknown name throws a `TypeError`.

`config` passes options to the UI: Scalar's configuration, the options of
Swagger UI and Redoc, or attributes of the Elements and RapiDoc components.
`title` sets the page title (default `API reference`).

```ts
specReference(
  {
    specUrl: "/api/openapi.json",
    ui: "swagger",
    title: "CRM API",
    config: { deepLinking: true, tryItOutEnabled: true },
  },
  request,
);
```

Elements renders with `router: "hash"` and `layout: "sidebar"` unless
`config` overrides them.

## Where the assets come from [#where-the-assets-come-from]

By default the page loads each UI from jsDelivr
(`https://cdn.jsdelivr.net/npm`) at the pinned version, and every script and
stylesheet tag carries a Subresource Integrity hash with
`crossorigin="anonymous"`, so the browser refuses a file that changed.

`assets` changes that:

| `assets`               | Loads from                                                                   |
| ---------------------- | ---------------------------------------------------------------------------- |
| unset                  | jsDelivr at the pinned versions, with SRI                                    |
| a string               | Another CDN with the same package paths, at the pinned versions, without SRI |
| `{ scripts, styles? }` | Files you host yourself, in the order given                                  |

```ts title="src/app/api/docs/route.ts"
import { specReference } from "better-supabase/spec";

export const GET = (request: Request) =>
  specReference(
    {
      specUrl: "/api/openapi.json",
      ui: "scalar",
      assets: { scripts: ["/vendor/scalar/standalone.js"] },
      csp: true,
    },
    request,
  );
```

Self-hosted files carry no integrity attribute: you serve them, so you
control what they contain.

## Content Security Policy [#content-security-policy]

`nonce` sets a nonce on every script and style tag the page writes, for a
policy your app already sends. `csp: true` sends a policy with the response
and generates a nonce when you don't pass one:

| Directive                 | Value                                             |
| ------------------------- | ------------------------------------------------- |
| `default-src`             | `'none'`                                          |
| `script-src`              | the nonce and the asset origins                   |
| `style-src`               | `'self'`, `'unsafe-inline'` and the asset origins |
| `img-src`                 | `'self' data: https:`                             |
| `font-src`                | `'self' data: https:`                             |
| `connect-src`             | `'self' https:`                                   |
| `worker-src`              | `'self' blob:`                                    |
| `base-uri`, `form-action` | `'none'`                                          |
| `frame-ancestors`         | `'self'`                                          |

The asset origins are those of the URLs the page loads (jsDelivr by
default); self-hosted relative paths add none. `specReferenceCsp({ nonce,
ui, assets })` returns the same policy string, for an app that sets its
headers elsewhere.

## The HTML alone [#the-html-alone]

`specReferenceHtml(options)` returns the page as a string, with the same
options minus `csp` and `cacheControl`. Use it to write a static file or to
build your own response. [`spec emit --ui`](/docs/cli/spec#reference-pages)
uses it to write `openapi.html` next to `openapi.json`.

## Sign in from Scalar [#sign-in-from-scalar]

`scalarPreset(document, options)` adds Scalar's `x-scalar-*` extensions to
an OpenAPI document, so the "try it" panel can sign in through the OAuth
flows the document declares. It returns a copy. Use it in a
[`transform`](/docs/specs#transform-one-version):

```ts title="src/lib/api.ts"
import { scalarPreset } from "better-supabase/spec";

const { document } = api.openapi({
  transform: ({ document }) =>
    scalarPreset(document, {
      clientId: "docs",
      redirectUri: "https://example.com/api/docs",
    }),
});
```

| Option          | Effect                                                                                |
| --------------- | ------------------------------------------------------------------------------------- |
| `clientId`      | `x-scalar-client-id` on every flow of each `oauth2` security scheme                   |
| `redirectUri`   | `x-scalar-redirect-uri` on the same flows                                             |
| `pkce`          | `x-usePkce` on the `authorizationCode` flow: `SHA-256` (default), `plain` or `no`     |
| `defaultScopes` | `x-default-scopes`, the scopes Scalar selects by default                              |
| `environments`  | `x-scalar-environments` at the document root: variables Scalar offers per environment |

Use a public client: the client id ends up in the document every visitor
can read.

# Resources

> REST resources that serve and document the same routes, with hooks for business logic, custom actions, response overrides and permissions checked by your authorizer.

Source: https://bettersupabase.com/docs/specs/resources

A resource serves a table as REST routes, and `defineApi` documents the same
object, so the routes and the spec can't drift. The adapters mount them:
[Hono](/docs/frameworks/hono#resources) with `bs.resource(table, options)`,
[Edge Functions](/docs/frameworks/edge#rest-resources) and
[Next.js](/docs/frameworks/next#rest-resources) with
`bs.resources(map, options)`, and [MCP](/docs/frameworks/mcp#table-tools)
as table tools.

```ts title="src/api/customers.ts"
import { defineAction } from "better-supabase/hono";
import { ok } from "better-supabase";

export const customers = {
  operations: ["list", "get", "update", "delete"],
  select: ["id", "name", "status", "organizationId"],
  input: { update: CustomerPatch },
  permissions: { update: "customers.update", delete: "customers.delete" },
  hooks: {
    beforeUpdate: (ctx, data) =>
      ok({ ...data, updatedBy: ctx.context.actor?.id }),
  },
  actions: {
    archive: defineAction({
      method: "POST",
      path: "/{id}/archive",
      summary: "Archive a customer",
      permission: "customers.archive",
      handler: (ctx, { id }) =>
        ctx.db.customers.update(String(id), { status: "archived" }),
    }),
  },
} as const;
```

```ts title="src/server.ts"
app.route("/api/customers", bs.resource("customers", customers));

export const api = defineApi(betterSupabase, {
  info: { title: "CRM API", version: "1.0.0" },
  basePath: "/api",
  authorizer,
  resources: { customers },
});
```

The options are the [resource description](/docs/specs/customize#resources)
plus what only the server needs: `select` (the columns every operation
returns), `input` (Standard Schemas that validate bodies before they reach
the repository), `hooks` and the action handlers.

| Route              | Operation | Success                            |
| ------------------ | --------- | ---------------------------------- |
| `GET /`            | `list`    | `200` page                         |
| `POST /`           | `create`  | `201` row                          |
| `GET /{key}`       | `get`     | `200` row, `404` when RLS hides it |
| `PATCH /{key}`     | `update`  | `200` row                          |
| `DELETE /{key}`    | `delete`  | `204`                              |
| an action's `path` | an action | `200` with an output, else `204`   |

A method a resource doesn't serve answers `405` with an `Allow` header, a
malformed key or body answers `400`, and a cross-site form post that rides
on the session cookie answers `403` with `code: "CROSS_SITE_REQUEST"`.

## Hooks [#hooks]

Hooks put business logic around the repository. Each one returns a
`Result` (or a promise of one): an error `Result` ends the request with that
error, and a thrown error becomes an `unexpected` error. They run after the
authorizer.

| Hook                                               | Runs                              | Returns              |
| -------------------------------------------------- | --------------------------------- | -------------------- |
| `authorize(ctx, operation, target)`                | before every operation and action | an error to deny it  |
| `beforeCreate(ctx, data)`                          | before the insert                 | the values to insert |
| `beforeUpdate(ctx, data, target)`                  | before the update                 | the values to update |
| `beforeDelete(ctx, target)`                        | before the delete                 | an error to stop it  |
| `afterList(ctx, page)`                             | after the list query              | the response         |
| `afterGet(ctx, row)`, `afterCreate`, `afterUpdate` | after the operation               | the response         |

`ctx` is `{ db, table, request, context }`: the repositories bound to the
caller (RLS applies), the table key, the HTTP request when there is one, and
the verified request context (actor, tenant and claims). `target` is
`{ id, data, query, row }`, where `row` is set when a permission check
already fetched the row.

## Change the response shape [#change-the-response-shape]

An `after*` hook can return another shape than the row. Describe it in
`output`, keyed by operation, so the document matches; for `list` it is the
item schema inside the page:

```ts
const customers = {
  output: { get: CustomerView, list: CustomerSummary },
  hooks: {
    afterGet: (ctx, row) => ok(toView(row)),
    afterList: (ctx, page) => ok({ ...page, items: page.items.map(toSummary) }),
  },
};
```

## Actions [#actions]

An action is a custom operation on the collection (`/export`) or on one row
(`/{id}/send`). `defineAction` types the handler's `data` and `query` from
the `input` and `query` schemas, which are validated first:

```ts
import { defineAction } from "better-supabase/hono";

const send = defineAction({
  method: "POST",
  path: "/{id}/send",
  input: SendInput,
  query: SendQuery,
  output: SendResult,
  emits: ["customer.sent"],
  handler: (ctx, { id, data, query }) => sendCustomer(ctx.db, id, data, query),
});
```

| Field                                  | What it does                                                         |
| -------------------------------------- | -------------------------------------------------------------------- |
| `method`                               | `GET`, `POST`, `PUT`, `PATCH` or `DELETE`                            |
| `path`                                 | Starts with `/`; the only parameter it may name is the table's key   |
| `input`, `query`, `output`             | Standard Schemas for the body, the query parameters and the response |
| `status`                               | Defaults to 200 with an `output`, 204 without                        |
| `summary`, `description`, `deprecated` | Documented on the operation                                          |
| `permission`                           | Checked by the authorizer before the handler runs                    |
| `emits`                                | Event names, written as `x-better-supabase-emits`                    |

The handler returns a `Result`, a plain value or a `Response`. Action names
start with a letter, use letters, digits and `_`, and can't be `list`,
`get`, `create`, `update` or `delete`. The operation id is
`<action><Table>` (`archiveCustomers`). `defineAction` is exported from
`better-supabase/hono`, `better-supabase/edge` and `better-supabase/next`.

## Permissions [#permissions]

`permissions` gives an operation a permission reference, and an action has
its own `permission`. The server's [authorizer](/docs/extending/authorizers)
decides each one, before the hooks run:

| Operation           | The resource the authorizer sees                          |
| ------------------- | --------------------------------------------------------- |
| `get`               | the table, the key and the row (fetched first, under RLS) |
| `update`, `delete`  | the table, the key and the current row (fetched first)    |
| `create`            | the table and the request body                            |
| `list`              | each row of the page, in one batch                        |
| an item action      | the table, the key and the row                            |
| a collection action | the table and the request body                            |

`list` filters instead of refusing: rows the authorizer denies are left out
of `items`, and the other page fields stay as the query returned them.
`permissions: true` asks the authorizer's `forOperation(table, operation)`
for every enabled operation; it throws when the authorizer has no
`forOperation`.

A refusal is a 403 [Problem Details](/docs/auth/problems) response with the
permission key:

```http
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json

{
  "type": "https://bettersupabase.com/problems/forbidden",
  "title": "Forbidden",
  "status": 403,
  "kind": "forbidden",
  "detail": "customers.delete needs an approval",
  "code": "APPROVAL_REQUIRED",
  "permission": "customers.delete",
  "approval": { "id": "apr_9" }
}
```

`code` is `PERMISSION_DENIED` for a denial and `APPROVAL_REQUIRED` when the
authorizer asks for an approval first; `approval.id` names the request to
approve. A declared permission without an authorizer denies every caller,
so a forgotten setup never opens a route. RLS still decides which rows the
caller can reach.

In the document, every operation with a permission carries
`x-better-supabase-permission` with the key, and its 403 is the shared
`PermissionDenied` response with examples for each code.

# Arazzo

> Arazzo 1.0 and 1.1 workflows over your OpenAPI and AsyncAPI documents, checked against the operations the API has.

Source: https://bettersupabase.com/docs/standards/arazzo

An [Arazzo](https://spec.openapis.org/arazzo/latest.html) document describes
sequences of API calls: create a row and read it back, walk a paginated list,
sign in and call an operation. `better-supabase/arazzo` renders the workflows
of the same API model as the [OpenAPI document](/docs/standards/openapi), so a
step that names a missing operation is a finding, not a broken document.

```ts title="lib/arazzo.ts"
import {
  createArazzo,
  createThenGetWorkflows,
  defineWorkflow,
  paginationWorkflows,
  type ResourceOperationIds,
} from "better-supabase/arazzo";
import { betterSupabase } from "./supabase";

const onboarding = defineWorkflow<ResourceOperationIds<"customers">>({
  id: "onboardCustomer",
  inputs: { type: "object", properties: { row: { type: "object" } } },
  steps: [
    {
      id: "create",
      operationId: "createCustomers",
      requestBody: "$inputs.row",
      outputs: { id: "$response.body#/id" },
    },
    {
      id: "get",
      operationId: "getCustomers",
      parameters: { id: "$steps.create.outputs.id" },
    },
  ],
});

export const arazzo = createArazzo(betterSupabase, {
  info: { title: "CRM workflows", version: "1.0.0" },
  resources: { customers: { pagination: "cursor" } },
  workflows: [onboarding, paginationWorkflows(), createThenGetWorkflows()],
});
```

`createArazzo` throws when rendering finds an error. To read every finding
instead, render the format from an API: `defineApi(...).render(arazzoFormat)`.

## Versions [#versions]

| Version | Pin                  | Status  |
| ------- | -------------------- | ------- |
| `1.0`   | `SPEC_PINS.arazzo10` | default |
| `1.1`   | `SPEC_PINS.arazzo11` | stable  |

1.0 is the default. Steps that send or receive on an AsyncAPI channel, and
`parameters` on success and failure actions, need 1.1; in 1.0 they are
findings (`arazzo-channel-step-unsupported`,
`arazzo-action-parameters-unsupported`).

## Built-in workflows [#built-in-workflows]

* `paginationWorkflows()`: for each cursor-paginated list, read the first
  page, then the next one from its `nextCursor`.
* `createThenGetWorkflows()`: for each resource with `create` and `get`,
  create a row and read it back by its key.
* `authWorkflow({ call })`: sign in, then call an operation with the access
  token. A 403 Problem Details answer with the code `APPROVAL_REQUIRED` ends
  the workflow, so a client can wait for the approval instead of retrying.

## Source descriptions [#source-descriptions]

The document points at the OpenAPI document as `api` (the model's `self`, or
`openapi.json`) and, in 1.1 when a step uses a channel, at the AsyncAPI
document as `events` (`asyncapi.json`). A workflow set with only channel
steps lists only the AsyncAPI source. Pass `openapi` and `asyncapi` to
`createArazzo`, or use `createArazzoFormat`, to point them at your published
URLs. [Arazzo](/docs/specs/arazzo) in the API documents section has every
option.

## Findings [#findings]

Rendering checks what a schema cannot: unknown operations and channels
(`arazzo-operation-unknown`, `arazzo-channel-unknown`), steps that read the
outputs of a later or unknown step (`arazzo-step-output-order`,
`arazzo-step-output-unknown`), undeclared inputs, duplicate ids and
`goto` targets that do not exist.

## Conformance [#conformance]

`arazzo.test.ts` renders the built-in workflows over a cursor-paginated
resource and validates the result against the official Arazzo 1.0 and 1.1
schemas.

# AsyncAPI

> An AsyncAPI 3.0 or 3.1 document for Realtime table changes, broadcast topics, CloudEvents and webhooks, rendered from the same API model as OpenAPI.

Source: https://bettersupabase.com/docs/standards/asyncapi

`better-supabase/asyncapi` describes the events your app sends and receives:
Realtime table change signals and broadcast topics on the Supabase Realtime
websocket, CloudEvents for row and block events, outgoing webhooks, and the
events HTTP operations emit. It reads the same API model as the
[OpenAPI document](/docs/standards/openapi), so a topic's permission and a
table's row schema are the same in both.

```ts title="lib/asyncapi.ts"
import { createAsyncApi } from "better-supabase/asyncapi";
import { betterSupabase } from "./supabase";

export const asyncapi = createAsyncApi(betterSupabase, {
  info: { title: "CRM events", version: "1.0.0" },
  realtimeUrl: "wss://example.supabase.co/realtime/v1/websocket",
  events: {
    tables: ["customers"],
    topics: {
      room: { template: "room:{id}", presence: true, events: { typing: true } },
    },
    cloudEvents: { source: "/crm", rows: ["customers"] },
  },
});
```

`createAsyncApi` throws when rendering finds an error. To read every finding
instead, render the format from an API: `defineApi(...).render(asyncapiFormat)`.

## Versions [#versions]

| Version | Pin                    | Status  |
| ------- | ---------------------- | ------- |
| `3.0`   | `SPEC_PINS.asyncapi30` | default |
| `3.1`   | `SPEC_PINS.asyncapi31` | stable  |

3.0 is the default because more tools read it. 3.1 only adds `ros2`
bindings, which better-supabase does not use, so the two documents differ in
the `asyncapi` field.

## What's in the document [#whats-in-the-document]

* **Servers:** the Realtime websocket from `realtimeUrl`, with a `ws` binding
  for the `apikey` query parameter Realtime reads on the upgrade, and the
  `servers` option for the HTTP channels.
* **Channels:** one per table change signal (`bs:t:<schema>.<table>`, with a
  `tenant` or `userId` parameter when the table is scoped) and per broadcast
  topic, with the topic template's parameters. Private topics, presence and
  client broadcasts (`send: true`) show up as channel fields and
  operations. CloudEvents and outgoing webhooks get one HTTP channel each.
* **Messages:** a table change signal (`schema`, `table` and `operation`;
  clients refetch the row through row-level security), one message per
  broadcast event, CloudEvents for row and block events with their headers
  as a shared `cloudEvent` message trait (see
  [CloudEvents](/docs/standards/events)), and webhooks with the
  [Standard Webhooks](/docs/standards/webhooks) headers.
* **Operations:** `receive` for what clients hear and `send` for what they
  may broadcast. Outgoing webhooks and events that HTTP operations emit are
  operations too.

[AsyncAPI](/docs/specs/asyncapi) in the API documents section has every
option.

## Schemas [#schemas]

The AsyncAPI Schema Object is based on JSON Schema draft-07. A payload that
uses a 2020-12 keyword (`$defs`, `prefixItems`, `unevaluatedProperties` and
others) reports an `asyncapi-schema-dialect` warning, because tools may not
read it.

## Extensions [#extensions]

Channels, messages and operations carry `x-better-supabase-table`,
`x-better-supabase-event`, `x-better-supabase-private`,
`x-better-supabase-permission`, `x-better-supabase-cloudevent-type`,
`x-better-supabase-dataschema` and `x-better-supabase-operation`. Like the
OpenAPI extensions, they document the model and follow `extensionPrefix`;
nothing in better-supabase reads them back.

## Conformance [#conformance]

`asyncapi.test.ts` renders a document with table changes, a private topic
with presence and client broadcasts, and CloudEvents, and validates it
against the official AsyncAPI 3.0.0 and 3.1.0 schemas.

# AuthZEN

> How the Authorizer interface follows the AuthZEN Authorization API 1.0 information model, and what the conformance test checks.

Source: https://bettersupabase.com/docs/standards/authzen

The runtime [`Authorizer`](/docs/extending/authorizers) follows the
[AuthZEN Authorization API 1.0](https://openid.net/specs/authorization-api-1_0.html)
information model, pinned in `SPEC_PINS.authzen`. better-supabase is the
policy enforcement point: it builds each access request, asks the authorizer
(the decision point) and enforces the answer. It does not speak the AuthZEN
HTTP API itself; an adapter that calls a remote decision point over that API
maps the request one to one.

## The request [#the-request]

| AuthZEN field | Authorizer field                        | Where it comes from                                                                                      |
| ------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Subject       | `subject` (`type`, `id`, `properties`)  | The verified request context only: `user`, `service`, `anon`, or `agent` for a token with an `act` chain |
| Action        | `action`                                | The declared permission reference; `key()` gives its string form                                         |
| Resource      | `resource` (`type`, `id`, `properties`) | The table, topic or action target; `get`, `update` and `delete` pass the row as `properties`             |
| Context       | `context`                               | The tenant, the assurance level (`aal`) and the token's scopes                                           |

The subject is never read from the request body, a header or a parameter, so
a caller cannot claim to be someone else.

## Decisions [#decisions]

AuthZEN returns a boolean `decision`. The `Authorizer` returns one of three
outcomes, each with an optional `context`:

* `granted`: the request goes ahead.
* `denied`: a `forbidden` error with the code `PERMISSION_DENIED` and the
  optional `reason`.
* `approval-required`: a `forbidden` error with the code `APPROVAL_REQUIRED`
  and the approval id, so a client can wait for a person to approve.

An adapter for a decision point that only returns a boolean maps `true` to
`granted` and `false` to `denied`.

## Failing closed [#failing-closed]

A missing authorizer when a permission is declared, a `key()` or
`evaluate()` that throws, and an answer that is not one of the three
outcomes all deny. The checks return a `Result` and never throw.

## Batches [#batches]

`evaluations` is the batch form (AuthZEN Access Evaluations). Results keep
the order of the requests. Without it, each request is evaluated on its own.
A failed batch denies every request in it.

## Conformance [#conformance]

`authzen.test.ts` checks that the request carries subject, action, resource
and context built from the verified context, that batch results are read in
the request order, and that a failing decision point or a missing decision
denies. `testAuthorizer` in `better-supabase/testing` runs the contract
checks against your adapter, including that the subject is never read from
input.

# CloudEvents

> Mutations as CloudEvents 1.0, for queues, buses and webhooks.

Source: https://bettersupabase.com/docs/standards/events

`better-supabase/events` turns repository mutations into
[CloudEvents](https://cloudevents.io) and sends them to an `EventSink`.

```ts
import { forwardMutations, httpSink } from "better-supabase/events";

const stop = forwardMutations(
  betterSupabase,
  httpSink("https://events.example.com/ingest"),
  {
    source: "https://crm.example.com",
    filter: (notice) => notice.table !== "auditLog",
  },
);
```

Each mutated row becomes one event:

```json
{
  "specversion": "1.0",
  "id": "5f0c…",
  "source": "https://crm.example.com",
  "type": "dev.better-supabase.row.created",
  "subject": "customers/5f0c…",
  "time": "2026-01-01T00:00:00.000Z",
  "datacontenttype": "application/json",
  "data": {
    "table": "customers",
    "row": { "id": "5f0c…", "name": "Acme" },
    "actorId": "user-1"
  },
  "partitionkey": "org-1"
}
```

The types are `row.created`, `row.updated`, `row.upserted`, `row.deleted` and
`row.softdeleted`. Use `typePrefix: 'com.acme.crm'` to use your own namespace.
`subject` is the primary key, `partitionkey` the tenant (also the one
`tenant()` resolved from the claims) and `data.actorId` the acting user. The
actor is in `data`, not in a context attribute, because intermediaries read
context attributes and they must not carry personal data. Writes
that return no rows send one event per known primary key, with the key as
`data.row`.

## Sinks [#sinks]

`EventSink` has one method, `send(events)`. Implement it for SQS, Pub/Sub,
Inngest or an outbox table. Sink failures go to `onError` and never fail
the mutation. Events are forwarded after the write, so use the SQL modules'
outbox when every event must arrive.

`httpSink(url, { mode })` POSTs in the HTTP binding's `batch` (default),
`structured` or `binary` mode. On the receiving side, `fromHttp(request)`
reads all three modes, and `isCloudEvent` validates the required attributes.
`toCloudEvents(notice, options)` builds events without a sink.

### Sends after the response [#sends-after-the-response]

A sink's `send` runs after the write, so a serverless function can stop
before it finishes. `forwardMutations` tracks each send on
`betterSupabase.events`: `events.pending` says whether one is running and
`events.settled()` resolves when they all have, failed ones included. The
adapters wait for them for you. `bs.route` and `bs.action` in Next.js pass
them to `after()`. The edge handlers, the Hono middleware, oRPC's
`fetchHandler` and both MCP adapters pass them to the `waitUntil` option (see
[Edge Functions](/docs/frameworks/edge#background-work)); Hono also uses
`c.executionCtx` on Workers. Elsewhere, await
`betterSupabase.events.settled()` before the process exits.

# Standards registry

> Every standard better-supabase follows, where it is used and how mature it is.

Source: https://bettersupabase.com/docs/standards

Every standard is **opt-in**: it lives in its own subpath or behind an option,
and nothing in the core requires it. Specs that are still drafts or versioned
conventions are pinned in code in `SPEC_PINS`
(`import { SPEC_PINS } from 'better-supabase'`), so an upgrade is always an
explicit change.

## Posture [#posture]

* **Adopted:** implemented, tested and covered by semver.
* **Adopted (pinned):** implemented against a pinned version of a draft or
  evolving convention. Moving the pin is a minor release with a changeset.
* **Adopted (draft):** implemented against a draft that has no release yet. It
  is opt-in, never a default, warns when it is used, and its fields may change
  with the draft.
* **Tracked:** not implemented yet; recorded so the design leaves room for it.

## Registry [#registry]

Each adopted standard has a conformance test in
`packages/better-supabase/tests/standards`. Where an official JSON Schema
exists (OpenAPI, Overlay, Arazzo, AsyncAPI, SARIF, CloudEvents, MCP, the MCP
Registry), the test
validates the output against a vendored copy of it.

| Standard                                                                                                                                                                                                 | Posture          | Where                                                                                                                                           | Tests                                         |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| [Standard Schema v1](https://standardschema.dev)                                                                                                                                                         | Adopted          | validation plugin, `route()`/`action()` inputs, `/list`, `/env`, typed jsonb, `defineRpc`                                                       | `standard-schema.test.ts`                     |
| [Standard JSON Schema](https://standardschema.dev/json-schema)                                                                                                                                           | Adopted          | converting user schemas for OpenAPI and [MCP tools](/docs/frameworks/mcp#custom-tools)                                                          | `standard-schema.test.ts`                     |
| [JSON Schema 2020-12](https://json-schema.org/draft/2020-12)                                                                                                                                             | Adopted          | generated table schemas, config, doctor report and metadata `$schema` URLs                                                                      | `json-schema-2020-12.test.ts`                 |
| [OpenAPI 3.0, 3.1 and 3.2](https://spec.openapis.org/oas/v3.2.0)                                                                                                                                         | Adopted (pinned) | [`/openapi`](/docs/standards/openapi): `createOpenApi`, `openapiFormat` and `better-supabase openapi emit`; 3.1 is the default                  | `openapi.test.ts`, `openapi-versions.test.ts` |
| [OpenAPI 3.3 draft (`v3.3-dev`) and Security Profiles](https://github.com/OAI/OpenAPI-Specification/tree/v3.3-dev)                                                                                       | Adopted (draft)  | `version: "3.3-preview"` on [`/openapi`](/docs/standards/openapi#33-preview), never a default                                                   | `openapi-versions.test.ts`                    |
| [Overlay 1.0, 1.1 and 1.2](https://spec.openapis.org/overlay/v1.1.0)                                                                                                                                     | Adopted (pinned) | [`/overlay`](/docs/standards/overlay): `defineOverlay`, `applyOverlay` and `overlayFromDiff`, with RFC 9535 JSONPath                            | `overlay.test.ts`                             |
| [Arazzo 1.0 and 1.1](https://spec.openapis.org/arazzo/v1.0.1)                                                                                                                                            | Adopted (pinned) | [`/arazzo`](/docs/standards/arazzo): workflows over the emitted OpenAPI document                                                                | `arazzo.test.ts`                              |
| [AuthZEN Authorization API 1.0](https://openid.net/specs/authorization-api-1_0.html)                                                                                                                     | Adopted (pinned) | [the `Authorizer` interface](/docs/standards/authzen): subject, action, resource and context, batch order, fail-closed                          | `authzen.test.ts`                             |
| [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457)                                                                                                                                       | Adopted          | `toProblem()`, every server adapter                                                                                                             | `rfc9457-problem-details.test.ts`             |
| [RFC 6750 Bearer tokens](https://www.rfc-editor.org/rfc/rfc6750)                                                                                                                                         | Adopted          | `WWW-Authenticate` on 401, `insufficient_scope` on 403 from `/mcp`, `resolveAuth`                                                               | `rfc6750-bearer.test.ts`                      |
| [RFC 9728 Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728)                                                                                                                           | Adopted          | [`/mcp`](/docs/frameworks/mcp): the metadata document, `scopes_supported` and `resource_metadata` in every challenge                            | `rfc9728-protected-resource.test.ts`          |
| [RFC 7518 ES256 JSON Web Keys](https://www.rfc-editor.org/rfc/rfc7518#section-3.4)                                                                                                                       | Adopted          | `better-supabase keys`, the local stack and [`asUser`](/docs/testing) sign with ES256; HS256 is explicit and local-only                         | `rfc7518-es256.test.ts`                       |
| [MCP 2026-07-28 (Streamable HTTP, authorization)](https://modelcontextprotocol.io/specification/2026-07-28)                                                                                              | Adopted (pinned) | [`/mcp`](/docs/frameworks/mcp) serves stateless 2026-07-28 requests and the 2025-11-25, 2025-06-18 and 2025-03-26 handshake                     | `mcp.test.ts`                                 |
| [OpenTelemetry DB semantic conventions](https://opentelemetry.io/docs/specs/semconv/database/)                                                                                                           | Adopted (pinned) | [`/otel`](/docs/standards/otel)                                                                                                                 | `otel-semconv.test.ts`                        |
| [W3C Server Timing (Working Draft)](https://www.w3.org/TR/2026/WD-server-timing-20260407/)                                                                                                               | Adopted (pinned) | `bs.proxy({ serverTiming: true })` emits `bs-proxy` and `bs-verify` ([middleware](/docs/auth/middleware#server-timing))                         | `w3c-server-timing.test.ts`                   |
| [W3C Trace Context](https://www.w3.org/TR/trace-context/)                                                                                                                                                | Adopted          | `/otel` propagates `traceparent` through the supabase-js `fetch`                                                                                | `w3c-trace-context.test.ts`                   |
| [CloudEvents 1.0](https://cloudevents.io)                                                                                                                                                                | Adopted          | [`/events`](/docs/standards/events)                                                                                                             | `cloudevents.test.ts`                         |
| [Standard Webhooks](https://www.standardwebhooks.com)                                                                                                                                                    | Adopted          | [`/webhooks`](/docs/standards/webhooks) (Supabase Auth hooks, database webhooks), the [webhook inbox](/docs/blocks/jobs#webhook-inbox)          | `standard-webhooks.test.ts`                   |
| [Idempotency-Key header (IETF draft)](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/)                                                                                       | Adopted (draft)  | [`/jobs`](/docs/blocks/jobs#idempotency-keys)                                                                                                   | `idempotency-key.test.ts`                     |
| [pgTAP](https://pgtap.org)                                                                                                                                                                               | Adopted          | [`sql add pgtap`](/docs/testing#pgtap)                                                                                                          | `sql-modules-standards.test.ts`               |
| [WinterTC minimum common API](https://min-common-api.proposal.wintertc.org)                                                                                                                              | Adopted          | runtime entries import no Node built-ins (checked in CI)                                                                                        | `wintertc.test.ts`                            |
| [AbortSignal](https://dom.spec.whatwg.org/#interface-AbortSignal)                                                                                                                                        | Adopted          | every repository, storage and RPC call takes `signal`                                                                                           | `abort-signal.test.ts`                        |
| [Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management)                                                                                                            | Adopted          | `await using tx`, `using sub`                                                                                                                   | `explicit-resource-management.test.ts`        |
| [PostgREST aggregate functions (12+)](https://docs.postgrest.org/en/v12/references/api/aggregate_functions.html)                                                                                         | Adopted (pinned) | [`aggregate()` and `_sum`/`_avg`/`_min`/`_max` includes](/docs/repository/aggregates)                                                           | `postgrest-aggregates.test.ts`                |
| [Stripe Sync Engine schema](https://github.com/supabase/stripe-sync-engine/tree/v0.48.5/packages/sync-engine/src/database/migrations)                                                                    | Adopted (pinned) | the [`entitlements` SQL module](/docs/blocks/entitlements) reads `stripe.active_entitlements`                                                   | `sql-modules-standards.test.ts`               |
| [pgvector iterative index scans (0.8+)](https://github.com/pgvector/pgvector#iterative-index-scans)                                                                                                      | Adopted (pinned) | the [`vector-search` SQL module](/docs/blocks/vector-search) sets `hnsw.iterative_scan`                                                         | `sql-modules-standards.test.ts`               |
| [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html)                                                                                                                          | Adopted          | `doctor --format sarif`                                                                                                                         | `sarif.test.ts`                               |
| [Agent Skills](https://agentskills.io)                                                                                                                                                                   | Adopted          | `skills/` in the package, `npx skills add ScaleDockHQ/better-supabase`, `better-supabase skills install` ([for AI agents](/docs/for-ai-agents)) | `agent-standards.test.ts`                     |
| [AGENTS.md](https://agents.md) and [llms.txt](https://llmstxt.org)                                                                                                                                       | Adopted          | repo root and these docs (`/llms.txt`, `/llms-full.txt`, `/docs/<page>.md`)                                                                     | `agent-standards.test.ts`                     |
| [MCP Registry `server.json`](https://github.com/modelcontextprotocol/registry)                                                                                                                           | Adopted          | the read-only docs MCP server at `/mcp` ([for AI agents](/docs/for-ai-agents#docs-mcp-server))                                                  | `mcp-registry-server-json.test.ts`            |
| [npm provenance](https://docs.npmjs.com/generating-provenance-statements)                                                                                                                                | Adopted          | release workflow                                                                                                                                | `npm-provenance.test.ts`                      |
| [OCSF 1.9.0](https://schema.ocsf.io/1.9.0)                                                                                                                                                               | Adopted (pinned) | `exportAuditLog({ format: "ocsf" })` in the [audit block](/docs/blocks/audit)                                                                   | `ocsf.test.ts`                                |
| [OpenFeature 0.9.0](https://github.com/open-feature/spec/tree/v0.9.0)                                                                                                                                    | Adopted (pinned) | `createFlagsProvider()` and `createFlagClient()` in the [flags block](/docs/blocks/flags)                                                       | `openfeature.test.ts`                         |
| [SCIM 2.0 (RFC 7643, RFC 7644)](https://www.rfc-editor.org/rfc/rfc7644)                                                                                                                                  | Adopted (pinned) | `scimHandler()` in the [SSO block](/docs/blocks/sso) serves `/Users`, `/Groups` and discovery                                                   | `scim.test.ts`                                |
| [Supabase SDK diagnostic logging (capability matrix 1.14.0)](https://github.com/supabase/sdk/blob/capability-matrix/v1.14.0/packages/capability-matrix/specs/client/observability/diagnostic_logging.md) | Adopted (pinned) | `defineSupabase(schema, { diagnostics: true })` writes redacted debug records to `logger` ([events](/docs/extending/events#diagnostics))        | `supabase-sdk-diagnostic-logging.test.ts`     |
| [better-supabase AI message v1](/docs/blocks/ai-chat)                                                                                                                                                    | Adopted (pinned) | `ai_messages.parts` in the [AI chat block](/docs/blocks/ai-chat), `schemas/ai-message-v1.json`                                                  | `ai-message.test.ts`                          |
| [AI SDK UI message stream protocol v1](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol)                                                                                                                | Adopted (pinned) | `createAssistant()` in [`better-supabase/ai-sdk/chat`](/docs/ai-sdk/chat), resumed from the stream store                                        | `ai-sdk-ui-message-stream.test.ts`            |
| [Anthropic memory tool `memory_20250818`](https://docs.claude.com/en/docs/agents-and-tools/tool-use/memory-tool)                                                                                         | Adopted (pinned) | `memoryTool()` and `anthropicMemory()` in [`better-supabase/ai-sdk/memory`](/docs/ai-sdk/memory), on the [memory block](/docs/blocks/memory)    | `anthropic-memory-tool.test.ts`               |
| [AsyncAPI 3.0 and 3.1](https://www.asyncapi.com/docs/reference/specification/v3.0.0)                                                                                                                     | Adopted (pinned) | [`/asyncapi`](/docs/standards/asyncapi): Realtime topics, table broadcasts and events                                                           | `asyncapi.test.ts`                            |

## Pins [#pins]

```ts
import { SPEC_PINS } from "better-supabase";

SPEC_PINS.otelSemconv; // OpenTelemetry semantic conventions version
SPEC_PINS.mcp; // MCP protocol revision (2026-07-28)
SPEC_PINS.openapi; // default OpenAPI version emitted (the same as openapi31)
SPEC_PINS.openapi30; // OpenAPI 3.0 patch release `version: "3.0"` emits
SPEC_PINS.openapi31; // OpenAPI 3.1 patch release `version: "3.1"` emits
SPEC_PINS.openapi32; // OpenAPI 3.2 patch release `version: "3.2"` emits
SPEC_PINS.openapi33Preview; // commit of the v3.3-dev branch `3.3-preview` follows
SPEC_PINS.securityProfiles; // state of the Security Profiles proposal `3.3-preview` follows
SPEC_PINS.overlay10; // Overlay 1.0 release `applyOverlay` reads
SPEC_PINS.overlay11; // Overlay 1.1 release, the default of `defineOverlay`
SPEC_PINS.overlay12; // Overlay 1.2 release (reusable actions, `$self`)
SPEC_PINS.arazzo10; // Arazzo 1.0 patch release
SPEC_PINS.arazzo11; // Arazzo 1.1 release (AsyncAPI steps)
SPEC_PINS.asyncapi30; // AsyncAPI 3.0 release
SPEC_PINS.asyncapi31; // AsyncAPI 3.1 release
SPEC_PINS.authzen; // AuthZEN Authorization API version the `Authorizer` vocabulary follows
SPEC_PINS.postgrestAggregates; // PostgREST version whose aggregate syntax is used
SPEC_PINS.serverTiming; // W3C Server Timing draft the proxy header follows
SPEC_PINS.stripeSyncEngine; // Stripe Sync Engine release whose tables the entitlements module reads
SPEC_PINS.pgvector; // minimum pgvector version for the vector-search module's iterative scans
SPEC_PINS.ocsf; // OCSF schema version of audit exports
SPEC_PINS.openfeature; // OpenFeature specification the flags provider follows
SPEC_PINS.scim; // SCIM version the SSO block's handler serves
SPEC_PINS.cloudevents; // CloudEvents version of `/events` envelopes
SPEC_PINS.standardWebhooks; // Standard Webhooks version `/webhooks` verifies
SPEC_PINS.sarif; // SARIF version of `doctor --format sarif`
SPEC_PINS.jsonSchema; // JSON Schema dialect of generated schemas
SPEC_PINS.supabaseSdkCapabilities; // Supabase SDK capability matrix the `diagnostics` option follows
SPEC_PINS.aiMessage; // version of the canonical AI message (`schemas/ai-message-v1.json`)
SPEC_PINS.aiSdkUiMessageStream; // AI SDK UI message stream protocol that `createAssistant` writes
SPEC_PINS.anthropicMemoryTool; // version of Anthropic's memory tool whose commands the memory block runs
```

# OpenAPI

> OpenAPI 3.0, 3.1 and 3.2 documents, and a 3.3 preview, rendered from one API model built from the generated schema.

Source: https://bettersupabase.com/docs/standards/openapi

`defineApi` builds one version-neutral API model from your tables, routes,
webhooks and security, and renders it to any OpenAPI version. Schemas come from
the same metadata as the repository, so the document can't drift from the
database types.

```ts title="lib/api.ts"
import { defineApi } from "better-supabase/spec";
import { customerList } from "./lists";
import { betterSupabase } from "./supabase";

export const api = defineApi(betterSupabase, {
  info: { title: "CRM API", version: "1.0.0" },
  servers: [{ url: "https://crm.example.com", name: "production" }],
  basePath: "/api",
  resources: {
    customers: { list: customerList },
    notes: { operations: ["list", "get"] },
  },
  security: ["bearer", "oauth2"],
  supabaseUrl: process.env.SUPABASE_URL,
});

const { document, diagnostics } = api.openapi({ version: "3.2" });
```

`api.openapi()` renders 3.1 when you pass no version. `createOpenApi` from
`better-supabase/openapi` takes the same options plus `version` and returns
the document directly; it throws when rendering finds an error. To write the
documents to disk and check them in CI, run `better-supabase spec check`.

## Versions [#versions]

| Version       | Status  | `openapi` field             | Pin                          |
| ------------- | ------- | --------------------------- | ---------------------------- |
| `3.0`         | legacy  | `SPEC_PINS.openapi30`       | `SPEC_PINS.openapi30`        |
| `3.1`         | default | `SPEC_PINS.openapi31`       | `SPEC_PINS.openapi31`        |
| `3.2`         | stable  | `SPEC_PINS.openapi32`       | `SPEC_PINS.openapi32`        |
| `3.3-preview` | preview | `3.3.0` (the draft's value) | `SPEC_PINS.openapi33Preview` |

Each version is lowered from the same model, and the output of 3.0, 3.1 and
3.2 is validated against the official schema of that version in
`openapi-versions.test.ts`. A field a version cannot hold is either moved to
an extension or left out with a diagnostic, never dropped silently.

### 3.0 [#30]

For tools that only read OpenAPI 3.0. Schemas are lowered from JSON Schema
2020-12 to the 3.0 Schema Object: `["string", "null"]` becomes
`nullable: true`, `const` becomes a one-value `enum`, and `examples` becomes
`example`. There is no `jsonSchemaDialect`, `info.summary` or
`license.identifier`. Webhooks are left out with a `webhooks-unsupported`
warning, and `QUERY` operations with `method-unsupported`.

### 3.1 [#31]

The default. Schemas are JSON Schema 2020-12, the 3.1 dialect, so nullable
columns become `["string", "null"]` and enums and CHECK unions become `enum`.
Webhooks render under `webhooks`. Fields that 3.2 added travel as the
registered `x-oai-*` extensions: `x-oai-$self`, a server's `x-oai-name`, a
response's `x-oai-summary` and a stream's `x-oai-itemSchema`. Fields without
a registered extension (nested tags, the OAuth metadata URL) use the model's
prefix, `x-better-supabase-` by default.

### 3.2 [#32]

The same fields as native 3.2 fields: `$self`, named servers, nested tags
with `parent`, `summary` and `kind`, `itemSchema` for streams,
`oauth2MetadataUrl`, and the `query` method. A 3.2 document does not validate
against the 3.1 schema, so pick it only when your tools read 3.2.

## 3.3-preview [#33-preview]

`3.3-preview` follows the OpenAPI `v3.3-dev` branch at the commit in
`SPEC_PINS.openapi33Preview`, with the Security Profiles proposal pinned in
`SPEC_PINS.securityProfiles`. You opt in by passing the version; it is never a
default. On top of 3.2 it adds:

* `security` on the Path Item, when every operation of the path shares it.
* Security schemes of `type: profile` with `profileMetadata`, and
  `components.securityProfileRequirements`. Other versions leave profile
  schemes out with a `preview-only` info finding.
* An `x-better-supabase-preview` field with the pin, so a reader can tell
  which draft the document follows.

Every render reports a `preview-version` warning, because the draft's fields
can change before 3.3 is released. There is no official 3.3 schema yet; the
test validates the preview against the 3.2 schema after removing the draft
fields.

## What's in the document [#whats-in-the-document]

* **Paths:** `GET /customers` (list), `POST /customers`,
  `GET|PATCH|DELETE /customers/{id}`. Views get `list` and `get` only. Item
  paths need a single-column primary key. Routes and resource actions add
  their own operations.
* **Schemas:** `CustomersRow`, `CustomersInsert`, `CustomersUpdate` and
  `CustomersPage`.
* **List parameters:** pass a [list query](/docs/platform/list) and its search,
  sort, page, size and facet parameters are used as they are. Otherwise
  `page`/`size`, or `after`/`size` with `pagination: 'cursor'` on the
  resource, capped by its `maxPageSize`. Cursor resources get a
  `CustomersPage` of `items`, `nextCursor` and `hasMore`.
* **Errors:** `400` to `422` responses use `application/problem+json` with a
  shared `Problem` schema that matches what [route handlers](/docs/auth/problems)
  send.
* **Security:** `supabaseJwt` (HTTP bearer with a Supabase access token),
  `supabaseOAuth` (authorization code flow against the Supabase OAuth server)
  and `supabaseApiKey` (the publishable key), or your own schemes.

## Extensions [#extensions]

Operations carry `x-better-supabase-table`, `x-better-supabase-operation`,
`x-better-supabase-action` and, when a permission is declared,
`x-better-supabase-permission` with the authorizer's key. Set
`extensionPrefix` to change the prefix.

These fields document the model for readers and tools. Nothing in
better-supabase reads them back: the server, Hono, Next.js, edge and MCP
surfaces serve the routes from the same API model the document is rendered
from, not from the document.

## Changing the output [#changing-the-output]

Use `transform` for the last change to the lowered document (it receives a
copy typed for the version), `fragments` for parsed OpenAPI pieces merged
over the generated document, and [Overlays](/docs/standards/overlay) for
changes you keep in separate files. The [document formats](/docs/extending/document-formats)
page shows how to add a format of your own.

# OpenTelemetry

> Database spans, metrics and trace propagation with the OpenTelemetry conventions.

Source: https://bettersupabase.com/docs/standards/otel

`better-supabase/otel` uses `@opentelemetry/api`, an optional peer
dependency. Set up an SDK as usual. Nothing is recorded without one.

```ts
import { otel, traceAuth, tracedFetch } from "better-supabase/otel";

export const betterSupabase = defineSupabase(schema).use(otel());
traceAuth(server); // auth.resolve and auth.refresh spans from betterSupabase.events

const supabase = createClient(url, key, { global: { fetch: tracedFetch() } });
```

## Spans [#spans]

Every repository operation gets a `CLIENT` span named after its
`db.query.summary`, for example `SELECT customers`. It follows the
[database semantic conventions](https://opentelemetry.io/docs/specs/semconv/database/)
at the version in `SPEC_PINS.otelSemconv`:

| Attribute                               | Example                               |
| --------------------------------------- | ------------------------------------- |
| `db.system.name`                        | `postgresql`                          |
| `db.namespace`                          | `postgres\|public`                    |
| `db.collection.name`                    | `customers`                           |
| `db.operation.name`                     | `SELECT`, `INSERT`, `UPSERT`, `COUNT` |
| `db.query.summary`                      | `SELECT customers`                    |
| `db.response.returned_rows`             | `20`                                  |
| `error.type`, `db.response.status_code` | `conflict`, `23505`                   |
| `better_supabase.executor`              | `postgrest` or `postgres`             |
| `server.address`, `server.port`         | `abc.supabase.co`, `443`              |

`db.namespace` is `{database}|{schema}`. The database defaults to `postgres`,
the name every Supabase project uses; set another with
`otel({ database: "crm" })`. `server.address` and `server.port` come from
`otel({ server: { address, port } })` and are left out without it.
`db.response.status_code` is set only for a Postgres SQLSTATE, not for
PostgREST's own codes such as `PGRST116`.

`db.$rpc` calls get a span named `EXECUTE <function>`, with
`db.operation.name` set to `EXECUTE` and the function in
`db.stored_procedure.name`. The reads in one `db.$many` call keep a span each.

Filter values, row data and query text are never recorded, so spans carry no
personal data by default.

## Metrics [#metrics]

`db.client.operation.duration` is a histogram in seconds with the system,
table, operation, `error.type` and the server attributes when they are set. Turn it off with `otel({ metrics: false })`.

## Trace context [#trace-context]

`tracedFetch()` wraps `fetch` so every PostgREST, Auth and Storage request
carries the W3C `traceparent` of the active span. Pass it to supabase-js as
`global.fetch`.

# Overlay

> Keep changes to a generated OpenAPI or AsyncAPI document in Overlay 1.0, 1.1 or 1.2 files and apply them on every render.

Source: https://bettersupabase.com/docs/standards/overlay

An [OpenAPI Overlay](https://spec.openapis.org/overlay/latest.html) is a list
of actions that change a document: each action selects nodes with an RFC 9535
JSONPath `target` and updates, copies or removes them. `better-supabase/overlay`
writes and applies overlays, so descriptions, examples and vendor fields you
add by hand survive the next generation.

```ts title="lib/overlays.ts"
import { defineOverlay } from "better-supabase/overlay";

export const docs = defineOverlay({
  info: { title: "CRM docs", version: "1.0.0" },
  actions: [
    {
      target: "$.paths['/api/customers'].get",
      update: { description: "Lists customers, newest first." },
    },
    {
      target: "$.paths.*[?(@['x-better-supabase-action'])]",
      update: { tags: ["actions"] },
    },
  ],
});
```

Pass overlays to a render. The engine needs an RFC 9535 JSONPath
implementation: `loadJsonPath()` loads the optional `jsonpath-rfc9535` peer,
or you pass your own `{ paths(document, expression) }`.

```ts title="lib/api.ts"
import { applyOverlays, loadJsonPath } from "better-supabase/overlay";
import { defineApi } from "better-supabase/spec";
import { docs } from "./overlays";
import { betterSupabase } from "./supabase";

const jsonpath = await loadJsonPath();

export const api = defineApi(betterSupabase, {
  info: { title: "CRM API", version: "1.0.0" },
  resources: { customers: {} },
  applyOverlays: (document, overlays) =>
    applyOverlays(document, overlays, { jsonpath }),
});

const { document } = api.openapi({ overlays: [docs] });
```

Overlays run after `transform` and before the format's lint, so a broken
change shows up in the render's diagnostics.

## Versions [#versions]

| Version | Pin                   | What it adds                                           |
| ------- | --------------------- | ------------------------------------------------------ |
| 1.0     | `SPEC_PINS.overlay10` | `update` and `remove` actions                          |
| 1.1     | `SPEC_PINS.overlay11` | `copy`, and the default version `defineOverlay` writes |
| 1.2     | `SPEC_PINS.overlay12` | reusable actions in `components.actions`, and `$self`  |

`defineOverlay` sets `overlay` to `1.1.0`, or to `1.2.0` when the input uses a
1.2 feature, and its return type follows. It throws when the actions list is
empty, when a feature needs a newer version than the one you set, or when a
reference names a missing reusable action. `expandReusableActions` turns a 1.2
overlay into a 1.1 one for tools that read only 1.1.

## Writing an overlay from a diff [#writing-an-overlay-from-a-diff]

`overlayFromDiff(base, enriched)` compares a generated document with a copy
you edited and writes the overlay that turns one into the other. Applying the
result to `base` gives a document equal to `enriched`. Pass `{ version: "1.0.0" }`
for tools that only read 1.0.

## Findings [#findings]

Applying an overlay never throws for a bad action. It reports a diagnostic
and skips the action:

* `overlay-no-match` (an error) when a target selects nothing. Set
  `x-better-supabase-allow-empty: true` on the action, or `allowEmpty` in the
  options, to accept it.
* `overlay-invalid-target`, `overlay-invalid-action`, `overlay-mixed-targets`,
  `overlay-merge-conflict`, `overlay-copy-source`, `overlay-ref-unresolved`,
  `overlay-version-feature`, `overlay-version-unsupported` and
  `overlay-empty-action` for the other problems.

## Conformance [#conformance]

`overlay.test.ts` validates the output of `defineOverlay`,
`overlayFromDiff` and `expandReusableActions` against the official Overlay
1.0, 1.1 and 1.2 schemas, and applies an overlay to a rendered OpenAPI
document without changing the input.

# Webhooks

> Standard Webhooks verification, typed Supabase Auth hooks and database webhook payloads.

Source: https://bettersupabase.com/docs/standards/webhooks

## Supabase Auth hooks [#supabase-auth-hooks]

Supabase signs HTTP Auth hooks with [Standard Webhooks](https://www.standardwebhooks.com).
`authHook` verifies the signature, types the payload for the hook and
serializes the answer:

```ts title="app/api/hooks/access-token/route.ts"
import { authHook, hookError } from "better-supabase/blocks/webhooks";

export const POST = authHook(
  "custom_access_token",
  process.env.AUTH_HOOK_SECRET!,
  async ({ user_id, claims }) => {
    const membership = await admin.memberships
      .findFirst({ where: { userId: user_id } })
      .orThrow();
    if (!membership) return hookError(403, "No organization");
    return { claims: { ...claims, tenant_id: membership.organizationId } };
  },
);
```

The hooks are `custom_access_token`, `send_email`, `send_sms`,
`mfa_verification_attempt`, `password_verification_attempt` and
`before_user_created`. Pass the secret as the dashboard shows it
(`v1,whsec_…`). Invalid signatures get a 401 before your handler runs.

The `tenant_id` claim this example adds is the one the
[tenant plugin](/docs/plugins/tenant) and the generated storage and realtime
policies read.

## Any Standard Webhook [#any-standard-webhook]

```ts
import { verifyWebhook, signWebhook } from "better-supabase/blocks/webhooks";

const result = await verifyWebhook<MyPayload>(request, [
  currentSecret,
  previousSecret,
]);
if (!result.ok) return problemResponse(result.error); // 401 with a WEBHOOK_* code
```

It checks `webhook-id`, `webhook-timestamp` (5 minutes of tolerance by
default) and every `v1` signature against each secret, in constant time. Pass
several secrets while rotating. Senders built on Svix use the same scheme
under `svix-id`, `svix-timestamp` and `svix-signature`; when a request has no
`webhook-id`, the `svix-` set is read instead, so one verifier takes both. The verified result carries `timestamp` as a
`Temporal.Instant`. `signWebhook(secret, { id, body })` gives the headers for
sending, and is useful in tests; pass `timestamp` (a `Temporal.Instant`) to fix
the signing time.

To send webhooks to your customers' endpoints, with subscriptions, retries
and a delivery log, use the [outgoing webhooks block](/docs/blocks/webhooks-out).

## Stripe webhooks [#stripe-webhooks]

Stripe signs with its own `Stripe-Signature` header (`t=…,v1=…`).
`verifyStripeWebhook(request, secret)` checks it with WebCrypto, so it runs
without the `stripe` package and on every runtime, and returns the parsed
event. Pass an array while rolling the endpoint secret. For the webhook
inbox, `stripeInboxVerify(secret)` is the `verify` option:

```ts
import { createWebhookInbox } from "better-supabase/blocks/jobs";
import { stripeInboxVerify } from "better-supabase/blocks/webhooks";

const inbox = createWebhookInbox(postgres.admin, {
  source: "stripe",
  verify: stripeInboxVerify(process.env.STRIPE_WEBHOOK_SECRET!),
});
```

`signStripeWebhook(secret, body)` builds the header for tests.

## Database webhooks [#database-webhooks]

Database webhooks (`pg_net`) aren't signed. Add a secret header when you
create them and check it with `verifySharedSecret(request, secret)`. Then
read the payload with `databaseChange`:

```ts
const change = databaseChange(
  betterSupabase,
  "customers",
  await request.json(),
);
if (change?.type === "INSERT") await welcome(change.record!.email);
```

`record` and `oldRecord` are app-cased and typed. `databaseChange` returns
`null` for other tables. Pair it with the SQL modules' webhook inbox to handle
each delivery once.

# Supabase packages

> Which Supabase packages better-supabase builds on, how each one is installed and which docs page covers it.

Source: https://bettersupabase.com/docs/supabase-packages

better-supabase adds types and glue on top of Supabase's own packages; it
replaces none of them. This page lists every Supabase package it touches, how
it gets into your app and where it is documented. [Peers](/docs/getting-started/peers)
lists the version range of every peer.

## Runtime packages [#runtime-packages]

These run in your app. Only the first four are dependencies of
`better-supabase`; you install `@supabase/supabase-js` yourself.

| Package                                                                                                    | What better-supabase uses it for                                                                                             | How it is installed | Docs                                |
| ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------- | ----------------------------------- |
| [`@supabase/server`](https://github.com/supabase/server)                                                   | Verifies access tokens locally, defines the claim, auth-mode and env types, and builds the RFC 9728 metadata for MCP servers | Dependency          | [Auth](/docs/auth)                  |
| [`@supabase/middleware`](https://github.com/supabase/middleware)                                           | The request pipeline (`ctx.supabase`, `ctx.postgres`) and the bridge for each framework                                      | Dependency          | [Middleware](/docs/auth/middleware) |
| [`@supabase/ssr`](https://github.com/supabase/ssr)                                                         | The browser client and the session cookie format                                                                             | Dependency          | [Auth](/docs/auth)                  |
| [`@supabase/postgrest-js`](https://github.com/supabase/supabase-js/tree/master/packages/core/postgrest-js) | A bare PostgREST client for `ctx.db` when no shared supabase-js client is passed                                             | Dependency          | [Repository](/docs/repository)      |
| [`@supabase/supabase-js`](https://github.com/supabase/supabase-js)                                         | Per-request user, anon and service clients, Storage, Realtime and the auth admin calls                                       | Required peer       | [Quickstart](/docs/getting-started) |

supabase-js brings the service clients along. better-supabase never imports
them directly; it reaches each one through the supabase-js client:

| Package                  | Reached through                                                                         | Docs                                            |
| ------------------------ | --------------------------------------------------------------------------------------- | ----------------------------------------------- |
| `@supabase/auth-js`      | `client.auth` (sign-in helpers, `auth.admin` for account deletion and suspension)       | [Auth](/docs/auth)                              |
| `@supabase/storage-js`   | `client.storage` (typed buckets, analytics and vector buckets)                          | [Storage](/docs/platform/storage)               |
| `@supabase/realtime-js`  | `client.channel` (typed topics, presence and live queries)                              | [Realtime](/docs/platform/realtime)             |
| `@supabase/functions-js` | `functions()` in `better-supabase/client`, the typed calls to `defineFunction` handlers | [Edge Functions](/docs/platform/edge-functions) |

`@supabase/gotrue-js` is the old name of `@supabase/auth-js` and is
deprecated; don't install it next to supabase-js. `@supabase/shared-types` is
an internal Supabase package, and better-supabase doesn't use it.

## CLI packages [#cli-packages]

`better-supabase gen` and the other commands load these only when they need
them. Apps that only run the runtime entries never install them.

| Package                                                                                    | What the CLI uses it for                                                                                            | How it is installed                                  | Docs                       |
| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | -------------------------- |
| [`@supabase/postgrest-typegen`](https://www.npmjs.com/package/@supabase/postgrest-typegen) | Reads the database catalog and writes `database.types.ts`, the same file `supabase gen types` writes                | Optional peer (`>=0.4.0 <0.5`, tested against 0.4.0) | [gen](/docs/cli/gen)       |
| [`@supabase/config`](https://www.npmjs.com/package/@supabase/config)                       | Interpolates `env()` in `supabase/config.toml` the way the Supabase CLI does; without it, smol-toml parses the file | Optional peer                                        | [Config](/docs/cli/config) |

## Supabase's MCP server [#supabases-mcp-server]

| Package                                                                                        | What better-supabase uses it for                                                                           | How it is installed              | Docs                                                                                                      |
| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------- |
| [`@supabase/mcp-server-supabase`](https://www.npmjs.com/package/@supabase/mcp-server-supabase) | The tool schemas `supabaseMcp()` types an agent's tools with, and the server `supabaseMcpHandler()` serves | Optional peer (`>=0.13.0 <0.14`) | [MCP connectors](/docs/ai-sdk/mcp#supabases-mcp-server), [MCP](/docs/frameworks/mcp#supabases-mcp-server) |

## The Supabase CLI [#the-supabase-cli]

The [`supabase`](https://www.npmjs.com/package/supabase) CLI runs your local
stack, and better-supabase works next to it rather than wrapping it:

* `better-supabase gen` writes the same `database.types.ts` as
  `supabase gen types`, through `@supabase/postgrest-typegen`, and adds its
  own files next to it. It reads the schema over `pg`, so it doesn't call the
  Supabase CLI.
* `better-supabase env` runs `supabase status` to read the local URLs and
  keys. It uses `$SUPABASE_BIN` when set, then `supabase` on the `PATH`, then
  `npx supabase`.
* `better-supabase init` prints the `supabase` commands to run next
  (`supabase start`, `supabase db reset`).

See [Local development](/docs/cli/local) for the commands and their output.

# Testing

> Test RLS, APIs and SQL against the local stack as real users.

Source: https://bettersupabase.com/docs/testing

`better-supabase/testing` signs tokens with the local stack's ES256 signing
key, the same kind of key a hosted project uses. The same user then works
through PostgREST, direct Postgres and your API, so tests exercise the real
RLS policies instead of mocks.

Without Docker, `liteStack()` runs [Supabase Lite](/docs/platform/lite#testing)
inside the test process on in-memory SQLite or PGlite, with the same
`asUser` and RLS, as long as the schema stays within what Lite supports.

## Signing key [#signing-key]

Create the key once with `better-supabase keys`. It writes
`supabase/signing_keys.json` and warns until the file is in `.gitignore`.
Point `supabase/config.toml` at it and restart the stack, so Auth signs with
it and the JWKS publishes it:

```toml title="supabase/config.toml"
[auth]
signing_keys_path = "./signing_keys.json"
```

```bash
pnpm better-supabase keys
supabase stop && supabase start
```

## asUser [#asuser]

```ts title="customers.test.ts"
import { asUser } from "better-supabase/testing";

const alice = await asUser(
  betterSupabase,
  { sub: aliceId, tenant_id: acme },
  { postgres },
);

expect(await alice.db.customers.count().orThrow()).toBe(3);
expect(await alice.sql!.customers.count().orThrow()).toBe(3);
await alice.supabase.storage.from("customer-logos").list(acme);
```

`asUser` reads the URL and publishable key from the environment, with or
without a framework prefix, just as [`better-supabase env`](/docs/cli/local#env)
writes them. It signs with the first key in the file `signing_keys_path`
points to (or `$SUPABASE_SIGNING_KEYS_PATH`); pass `signingKey` to use
another. `db` goes through PostgREST, and `sql` goes through direct Postgres
with the same claims, when you pass `postgres`.

A stack without a signing key still verifies tokens signed with the shared
JWT secret. Pass `{ alg: 'HS256' }` to sign that way; it reads
`SUPABASE_JWT_SECRET` and refuses any URL that is not local.

`signLocalJwt(claims)` signs a token the same way without building
repositories, for requests you send yourself.

## Fixtures [#fixtures]

Share rows between `supabase db reset` and your tests with a
[typed seed](/docs/cli/local#seed):

```ts
import { seed } from "../supabase/seed.ts";

beforeAll(() => seed.insert(postgres.admin));

const alice = await asUser(betterSupabase, {
  sub: aliceId,
  tenant_id: seed.rows.organizations.acme.id,
});
```

`supabaseClaimFixtures` holds access-token claims where someone acts for the
user, with the `actor`, `impersonator` and `delegation` each one reads as:
`supportSession`, `supportSessionReadOnly`, `impersonation`, `oauthClient`
and `agentChain`. They follow the claim contract every hook writes, so a test can sign
one and check a guard or a support banner:

```ts
import {
  createTestSigner,
  supabaseClaimFixtures,
} from "better-supabase/testing";

const signer = await createTestSigner();
const token = await signer.sign(supabaseClaimFixtures.supportSession.claims);
```

[Impersonation](/docs/auth/impersonation#reading-the-act-claim) lists what
each `act.kind` means.

## APIs [#apis]

ES256 test tokens verify against the local stack's JWKS, so your
[framework adapter](/docs/frameworks) needs no test-only resolver and sees
the same claims that RLS sees:

```ts
const bs = createHono(betterSupabase);
await app.request("/api/customers", {
  headers: { authorization: `Bearer ${alice.token}` },
});
```

For HS256 tokens, add `localAuth(secret)` to `auth.resolvers`. Use it in
tests only.

## Authorizers [#authorizers]

`staticAuthorizer` is an in-memory [authorizer](/docs/extending/authorizers)
for tests and examples. Permissions come from the subject's role and id,
never from the request's resource or context:

```ts
import { staticAuthorizer } from "better-supabase/testing";

const authorizer = staticAuthorizer({
  roles: {
    authenticated: ["customers.read"],
    service_role: ["customers.delete"],
  },
  grants: { [alice.id]: ["customers.delete"] },
  approvals: ["invoices.void"],
});

const bs = createHono(betterSupabase, { authorizer });
```

| Option      | What it grants                                                                                                         |
| ----------- | ---------------------------------------------------------------------------------------------------------------------- |
| `roles`     | Permissions per role: the subject's `role` property (the Postgres role, such as `authenticated`), or its type (`anon`) |
| `grants`    | Permissions per subject id, on top of the role's                                                                       |
| `approvals` | Permissions that answer `approval-required`, with the approval id `approval:<permission>`                              |

Anything else is denied. To check an authorizer of your own, run
`testAuthorizer` (see [Conformance](/docs/extending/conformance)).

## Tenant isolation [#tenant-isolation]

`expectTenantIsolation(betterSupabase, { tenants, tables })` checks that RLS keeps two
tenants apart. For each table it seeds one row per tenant through the service
role, then signs in as a user of each tenant and tries to select, insert,
update and delete the other tenant's row:

```ts title="isolation.test.ts"
import { expectTenantIsolation } from "better-supabase/testing";

it("keeps organizations apart", async () => {
  await expectTenantIsolation(betterSupabase, {
    tenants: [
      { id: ACME, name: "acme", claims: { sub: alice, tenant_id: ACME } },
      { id: GLOBEX, name: "globex", claims: { sub: bob, tenant_id: GLOBEX } },
    ],
    tables: {
      tags: {
        row: (tenant, n) => ({ organizationId: tenant.id, name: `iso-${n}` }),
        update: { color: "red" },
      },
    },
    seed: () => seed.insert(postgres.admin),
  });
});
```

* A leak is a row that comes back from a select, or an insert, update or
  delete that changed the other tenant's data. The helper checks the row
  through the service role, so a blocked update that returns 0 rows counts
  as blocked.
* Each user must also read its own row. Otherwise the test passes because
  the user can see nothing, and the helper fails with a hint about claims
  and memberships.
* `row(tenant, n)` builds a row owned by `tenant`. `n` is 0 for the seeded
  row and 1 for the insert attempt, so put it in unique columns. `update`
  must change the row.
* It runs without `betterSupabase`'s plugins, so the [`tenant()` plugin](/docs/plugins)
  can't hide a missing policy.
* On a leak it throws a `ConformanceError` naming every table and command
  that leaked, such as `tags: globex can't delete leaky's rows`. It removes
  every row it created either way.
* It needs the service-role key: `stack.secretKey` or `$SUPABASE_SECRET_KEY`.

[`doctor`](/docs/cli/doctor#bs107) reports tenant tables whose policies skip
a command before a test has to.

## Database budget [#database-budget]

`expectDbBudget(page, { maxCalls, maxWaves, during })` fails a Playwright
test when one render makes more database calls or sequential waves than
allowed. It reads the request id from every document and `_rsc` response
during `during` (a reload by default) and fetches each render's totals from
[`bs.debugRoute()`](/docs/frameworks/next-cache-components#budget):

```ts
import { expectDbBudget } from "better-supabase/testing";

const renders = await expectDbBudget(page, {
  maxCalls: 8,
  maxWaves: 2,
  during: () => page.goto("/customers"),
});
```

The failure lists each render over budget with its tables, so the diff that
added a query shows up in the message. The page type is structural, so
Playwright stays your dependency, not better-supabase's. The app needs
`createNext(betterSupabase, { debug: { enabled: true } })` for the test build.

A response the browser aborts mid-stream (the router does this for some
navigations) counts as finished, so the check doesn't wait out `timeoutMs`
for it. After `during` resolves, the check keeps listening until no new
response has arrived for `settleMs` (500 ms by default), so a render that
starts late, such as the dynamic render after an `instant()` lock releases,
is still counted.

## Instant navigations [#instant-navigations]

`expectInstant(page, { during, visible, absent, maxCalls, maxWaves })` runs
`during` inside `@next/playwright`'s
[`instant()`](/docs/frameworks/next-cache-components#testing-instant-navigations),
then checks what the prefetched UI shows while the lock still holds: every
`visible` locator must be visible, and every `absent` locator must match
nothing. With `maxCalls` or `maxWaves` it also checks the navigation's
database budget, as `expectDbBudget` does, and returns the measured renders.

```ts title="e2e/customers.spec.ts"
import { expect, test } from "@playwright/test";
import { expectInstant } from "better-supabase/testing";

test("customers come from the per-session App Shell", async ({ page }) => {
  await page.goto("/");
  const link = page.getByRole("link", { name: "Customers", exact: true });
  await expect(link).toBeVisible();
  await expectInstant(page, {
    during: async () => {
      await link.click();
      await page.waitForURL((url) => url.pathname === "/customers");
    },
    visible: [
      page.getByRole("heading", { name: "Customers" }),
      page.getByText("Road Runner Inc"),
    ],
    maxCalls: 8,
    maxWaves: 2,
  });
});
```

* Install `@next/playwright` as a dev dependency. It is an optional peer that
  `expectInstant` loads when it runs, so apps that never call it don't need
  it.
* The app needs a `next build` with
  `experimental.exposeTestingApiInProductionBuild`; without it the lock is
  ignored and the check passes without testing anything. The budget options
  also need `debug.enabled` and `bs.debugRoute()`, as above.
* A missing `visible` locator fails after `timeoutMs` (5 seconds by
  default) with the locator in the message. Content that should stream in
  after the navigation, such as a count read per request, goes in `absent`
  (`absent: [page.getByTestId("unread-summary")]`); assert it with `expect`
  after `expectInstant` returns.
* Pass `baseURL` when `during` loads the first page, so the lock can be set
  before the page has a URL.
* With `maxCalls` or `maxWaves`, a navigation that never reaches the proxy
  fails: the client cache served it, so there is no render to measure. Drop
  the budget for that navigation; `visible` already proves it is instant.

## pgTAP [#pgtap]

`better-supabase sql add pgtap` writes
`supabase/tests/000_better_supabase_pgtap.test.sql`. It runs first and
installs helpers that later test files can use with `supabase test db`:

```sql title="supabase/tests/customers.test.sql"
begin;
select plan(2);

select tests.rls_enabled('public');

select tests.authenticate_as(tests.create_user('alice@example.com'), '{"tenant_id": "00000000-0000-4000-8000-000000000001"}');
select is((select count(*) from public.customers where organization_id <> '00000000-0000-4000-8000-000000000001'), 0::bigint, 'no cross-tenant rows');

select * from finish();
rollback;
```

| Helper                                                  | Does                                                                               |
| ------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `tests.create_user(email, app_metadata, user_metadata)` | Inserts a confirmed user and returns its id; call it before switching roles        |
| `tests.authenticate_as(user_id, claims)`                | Switches to `authenticated` with the user's claims for the rest of the transaction |
| `tests.authenticate_as_anon()`                          | Switches to `anon`                                                                 |
| `tests.clear_authentication()`                          | Switches back to the test runner's role                                            |
| `tests.rls_enabled(schema)`                             | Fails and lists every table in the schema without row level security               |

The helpers stay installed after the run, so `supabase db advisors` sees
them too. Each one sets its own `search_path`, so the advisors report no
`function_search_path_mutable` warning for them; run `sql sync` after
upgrading to rewrite a module file from an earlier version.